Publish documentation / publish (push) Successful in 34s
Reset the activity panel when starting a new question so the landing navigation is restored. Detect and persist the original question language, pass it through runtime and widget descriptors, and scope HITL controls to that language. Validated with gate, session, backend and frontend tests, TypeScript checks, Ruff and strict docs build. Rebuilt and restarted local core/frontend; both healthy and serving HTTP successfully.
1322 lines
64 KiB
TypeScript
1322 lines
64 KiB
TypeScript
import { useI18n } from "../i18n";
|
|
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 { WorkAreaPanelHost } from "./WorkAreaPanel";
|
|
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 { MemoryManagementPage } from "./MemoryManagementPage";
|
|
import { EvidenceManagementPage } from "./EvidenceManagementPage";
|
|
import { ArchiveNavigation } from "./ArchiveNavigation";
|
|
import { Pencil, ArrowLeft, ArrowRight, ChevronDown, Trash2 } from "lucide-react";
|
|
import { Accordion } from "@base-ui/react/accordion";
|
|
import { Button, buttonVariants } 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 { useIsMutating, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { CSSProperties, KeyboardEvent } from "react";
|
|
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation";
|
|
import { getSettings } from "../api/settings";
|
|
import { workspacePreferences } from "../workspaces/preferences";
|
|
import { getWorkspacePreprocessingStatus } from "../api/workspace-preprocessing";
|
|
import { AdministrationNavigationProvider, useAdministrationNavigation } from "./administration/AdministrationNavigation";
|
|
import { AdministrationHeader } from "./administration/AdministrationPage";
|
|
import { WorkingContextProvider, useWorkingContext } from "../workspaces/WorkingContext";
|
|
import { WorkingContextShelf } from "./WorkingContextShelf";
|
|
import { useInteractionModelBusy } from "../models/useInteractionModelBusy";
|
|
import { FullHeader } from "./host/FullHeader";
|
|
import { useShell } from "./host/ShellProvider";
|
|
import { SessionNotification } from "./host/SessionNotification";
|
|
import { readRememberedSession, rememberSession } from "./host/rememberedSession";
|
|
|
|
interface AppShellProps {
|
|
canLogout: boolean;
|
|
}
|
|
|
|
const SESSION_SCOPES: readonly SessionScope[] = ["mine", "all"];
|
|
|
|
function sessionScopeTabClass(selected: boolean): string {
|
|
const base = "relative flex min-h-[38px] w-full cursor-pointer items-center justify-center rounded-md border px-[11px] py-[3px] text-xs font-semibold tracking-[0.005em] outline-none focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-[oklch(var(--nav-active-border)/0.28)]";
|
|
return selected
|
|
? `${base} border-[oklch(var(--nav-active-border))] bg-[oklch(var(--nav-active))] text-[oklch(var(--nav-active-foreground))] hover:bg-[oklch(var(--nav-active-hover))]`
|
|
: `${base} border-border bg-card text-muted-foreground hover:border-muted-foreground/45 hover:bg-muted/55 hover:text-foreground`;
|
|
}
|
|
|
|
type DatabaseManagementPresentation = "legacy" | "fleet";
|
|
|
|
interface DatabaseManagementPresentationEnvironment {
|
|
isDevelopment: boolean;
|
|
mode: string;
|
|
legacyFlag?: string | boolean;
|
|
search: string;
|
|
}
|
|
|
|
/**
|
|
* Fleet Ledger is the release-safe default. The old surface can only be
|
|
* requested explicitly from a development server or a staging build, so an
|
|
* accidentally retained flag can never switch the production UI back.
|
|
*/
|
|
export function resolveDatabaseManagementPresentation({
|
|
isDevelopment,
|
|
mode,
|
|
legacyFlag,
|
|
search,
|
|
}: DatabaseManagementPresentationEnvironment): DatabaseManagementPresentation {
|
|
const legacyAllowed = isDevelopment || mode.trim().toLowerCase() === "staging";
|
|
if (!legacyAllowed) return "fleet";
|
|
|
|
const legacyRequestedByQuery = new URLSearchParams(search).get("db-ui")?.toLowerCase() === "legacy";
|
|
const legacyRequestedByEnvironment = String(legacyFlag ?? "").trim().toLowerCase() === "true";
|
|
return legacyRequestedByQuery || legacyRequestedByEnvironment ? "legacy" : "fleet";
|
|
}
|
|
|
|
export function AppShell({ canLogout }: AppShellProps) {
|
|
const { t } = useI18n();
|
|
const shell = useShell();
|
|
const workingContext = useWorkingContext();
|
|
const interactionBusy = useInteractionModelBusy();
|
|
const mutationBusy = useIsMutating() > 0;
|
|
const [adminBusy, setAdminBusy] = useState({ database: false, workspace: false, evidence: false, pi: false });
|
|
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 visible app bounds for dialogs, including embedded portal layouts.
|
|
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`);
|
|
const top = Math.max(0, r.top);
|
|
doc.style.setProperty("--app-area-top", `${top}px`);
|
|
doc.style.setProperty("--app-area-height", `${Math.max(0, Math.min(window.innerHeight, r.bottom) - top)}px`);
|
|
};
|
|
publish();
|
|
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(publish);
|
|
observer?.observe(el);
|
|
window.addEventListener("resize", publish);
|
|
window.addEventListener("scroll", publish, true);
|
|
return () => {
|
|
observer?.disconnect();
|
|
window.removeEventListener("resize", publish);
|
|
window.removeEventListener("scroll", publish, true);
|
|
doc.style.removeProperty("--app-area-left");
|
|
doc.style.removeProperty("--app-area-width");
|
|
doc.style.removeProperty("--app-area-top");
|
|
doc.style.removeProperty("--app-area-height");
|
|
};
|
|
}, [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 sessionScopeTabRefs = useRef<Record<SessionScope, HTMLButtonElement | null>>({
|
|
mine: null,
|
|
all: null,
|
|
});
|
|
const principal = authenticatedUser;
|
|
const permissions = authenticatedUser?.permissions ?? [];
|
|
const isAdmin = authenticatedUser?.isAdmin === true;
|
|
const canReadAllSessions = permissions.includes("session.read_all");
|
|
const canManageWorkspace = permissions.includes("workspace.manage");
|
|
const canManageWorkspaceSecrets = permissions.includes("workspace.secrets.manage");
|
|
const canManageDatabase = permissions.includes("database.manage");
|
|
const canManageMemory = permissions.includes("memory.manage");
|
|
const canManageEvidence = permissions.includes("evidence.manage");
|
|
const [memoryDirty, setMemoryDirty] = useState(false);
|
|
const [memoryBusy, setMemoryBusy] = useState(false);
|
|
const canManagePi = permissions.includes("pi.manage");
|
|
const authGeneration = useAuthGeneration();
|
|
const { data: installationSettings } = useQuery({
|
|
queryKey: ["settings"],
|
|
queryFn: getSettings,
|
|
});
|
|
// Only an ID selected from the active Workspace Registry may address workspace-scoped APIs.
|
|
// Installation settings are a legacy hint and can name a removed/non-catalog workspace.
|
|
const selectedWorkspaceId = workingContext.workspace?.id;
|
|
const preprocessingQuery = useQuery({
|
|
queryKey: ["workspace-preprocessing", selectedWorkspaceId],
|
|
queryFn: () => getWorkspacePreprocessingStatus(selectedWorkspaceId!),
|
|
enabled: Boolean(authenticatedUser && selectedWorkspaceId),
|
|
refetchInterval: (query) => query.state.data?.state === "running" ? 1_000 : 5_000,
|
|
});
|
|
const preprocessingBlocksNewSession = Boolean(
|
|
selectedWorkspaceId
|
|
&& preprocessingQuery.data
|
|
&& preprocessingQuery.data.state !== "ready",
|
|
);
|
|
const workspaceReady = Boolean(selectedWorkspaceId && !preprocessingQuery.isError && preprocessingQuery.data?.state === "ready");
|
|
const workspaceReadinessLabel = t("Workspace readiness: {state}", {
|
|
state: t(preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking"),
|
|
});
|
|
const { data: sessions = [], isSuccess: sessionsLoaded } = 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 restoredSelection = useRef(false);
|
|
useEffect(() => {
|
|
if (restoredSelection.current || !authenticatedUser || !sessionsLoaded) return;
|
|
const savedId = readRememberedSession(authenticatedUser);
|
|
const saved = sessions.find(session => session.id === savedId);
|
|
// A saved selection may belong to another user. The initial mine list is
|
|
// not exhaustive for reviewers authorized to browse all sessions.
|
|
if (savedId && !saved && sessionScope === "mine" && canReadAllSessions) {
|
|
setSessionScope("all");
|
|
return;
|
|
}
|
|
restoredSelection.current = true;
|
|
if (saved) setPanelSession(saved);
|
|
else if (savedId) rememberSession(authenticatedUser, null);
|
|
}, [authenticatedUser, sessions, sessionsLoaded, sessionScope, canReadAllSessions]);
|
|
|
|
const queryClient = useQueryClient();
|
|
const [showActivity, setShowActivity] = useState(false);
|
|
const databaseManagementPresentation = resolveDatabaseManagementPresentation({
|
|
isDevelopment: import.meta.env.DEV,
|
|
mode: import.meta.env.MODE,
|
|
legacyFlag: import.meta.env.VITE_DB_MANAGEMENT_LEGACY,
|
|
search: window.location.search,
|
|
});
|
|
const databaseNavigationRef = useRef({ dirty: false, busy: false });
|
|
const updateDatabaseNavigationState = useCallback((state: { dirty: boolean; busy: boolean }) => {
|
|
databaseNavigationRef.current = state;
|
|
setAdminBusy(previous => previous.database === state.busy ? previous : { ...previous, database: state.busy });
|
|
}, []);
|
|
const workspaceNavigationRef = useRef({ dirty: false, busy: false });
|
|
const updateWorkspaceNavigationState = useCallback((state: { dirty: boolean; busy: boolean }) => {
|
|
workspaceNavigationRef.current = state;
|
|
setAdminBusy(previous => previous.workspace === state.busy ? previous : { ...previous, workspace: state.busy });
|
|
}, []);
|
|
const { route, navigate } = useAdministrationNavigation(canLeaveDatabaseManagement, () => {
|
|
if (activeSurface === "memory-management") return memoryDirty || memoryBusy;
|
|
const state = activeSurface === "database-management" ? databaseNavigationRef.current
|
|
: activeSurface === "workspace-management" ? workspaceNavigationRef.current : null;
|
|
return Boolean(state && (state.dirty || state.busy));
|
|
});
|
|
const activeSurface = route.surface;
|
|
const [visited, setVisited] = useState<Set<string>>(() => new Set([activeSurface]));
|
|
useEffect(() => { setVisited(previous => previous.has(activeSurface) ? previous : new Set([...previous, activeSurface])); }, [activeSurface]);
|
|
const administrationBusy = mutationBusy || memoryBusy || Object.values(adminBusy).some(Boolean);
|
|
const contextLocked = interactionBusy || creatingSession || administrationBusy
|
|
|| preprocessingQuery.data?.state === "running";
|
|
const [adminNavigationValue, setAdminNavigationValue] = useState<string[]>([]);
|
|
const [activeOpen, setActiveOpen] = useState(false);
|
|
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 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) {
|
|
restoredSelection.current = true;
|
|
if (authenticatedUser) rememberSession(authenticatedUser, id);
|
|
// 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 sessionSelectionControls(list: SessionSummary[]) {
|
|
if (list.length === 0) return null;
|
|
const selected = list.filter((session) => selectedSessionIds.has(session.id));
|
|
return (
|
|
<div className="flex items-center justify-between gap-2 px-2 pb-2">
|
|
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground">
|
|
<Checkbox
|
|
checked={selected.length === list.length}
|
|
indeterminate={selected.length > 0 && selected.length < list.length}
|
|
onCheckedChange={(checked) => setSelectedSessionIds((current) => {
|
|
const next = new Set(current);
|
|
for (const session of list) {
|
|
if (checked) next.add(session.id);
|
|
else next.delete(session.id);
|
|
}
|
|
return next;
|
|
})}
|
|
/>
|
|
<span>{t("Select all")}</span>
|
|
</label>
|
|
{selected.length > 0 && (
|
|
<Button
|
|
variant="destructive"
|
|
size="xs"
|
|
aria-label={t("Delete {count} selected sessions", { count: selected.length })}
|
|
onClick={() => requestDelete(selected)}
|
|
>
|
|
<Trash2 />
|
|
{t("Delete ({count})", { count: selected.length })}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function handleSessionScopeTabKeyDown(
|
|
event: KeyboardEvent<HTMLButtonElement>,
|
|
current: SessionScope,
|
|
) {
|
|
const currentIndex = SESSION_SCOPES.indexOf(current);
|
|
let target: SessionScope | undefined;
|
|
if (event.key === "ArrowRight") target = SESSION_SCOPES[(currentIndex + 1) % SESSION_SCOPES.length];
|
|
else if (event.key === "ArrowLeft") target = SESSION_SCOPES[(currentIndex - 1 + SESSION_SCOPES.length) % SESSION_SCOPES.length];
|
|
else if (event.key === "Home") target = SESSION_SCOPES[0];
|
|
else if (event.key === "End") target = SESSION_SCOPES.at(-1);
|
|
if (!target) return;
|
|
event.preventDefault();
|
|
setSessionScope(target);
|
|
sessionScopeTabRefs.current[target]?.focus();
|
|
}
|
|
|
|
function canLeaveDatabaseManagement(): boolean {
|
|
const dirty = activeSurface === "workspace-management" ? workspaceNavigationRef.current.dirty
|
|
: activeSurface === "memory-management" ? memoryDirty
|
|
: activeSurface === "database-management" ? databaseNavigationRef.current.dirty : false;
|
|
if (dirty) {
|
|
toast.warning(t("Save your administration changes, or cancel the edit, before leaving this page."));
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function openPanel(id: string) {
|
|
if (!workingContext.ready) return;
|
|
if (!canLeaveDatabaseManagement()) return;
|
|
const s = sessions.find((x) => x.id === id);
|
|
if (!s) return;
|
|
restoredSelection.current = true;
|
|
if (authenticatedUser) rememberSession(authenticatedUser, id);
|
|
navigate({ surface: "core" }, true);
|
|
// 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, true);
|
|
return;
|
|
}
|
|
setPanelSession(s); setShowActivity(false);
|
|
}
|
|
function toggleActivity() {
|
|
setShowActivity((v) => {
|
|
const next = !v;
|
|
if (next) setPanelSession(null);
|
|
return next;
|
|
});
|
|
}
|
|
async function doResume(id: string, databaseExitApproved = false) {
|
|
if (!workingContext.ready) return;
|
|
if (!databaseExitApproved && !canLeaveDatabaseManagement()) return;
|
|
navigate({ surface: "core" }, true);
|
|
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;
|
|
// Bind context atomically with Resume, before displaying any session or Admin actions.
|
|
// Older servers omit this field; their manifest remains the compatibility fallback.
|
|
if (result.workspaceId) workspacePreferences.selectWorkspace(result.workspaceId);
|
|
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; workspace_id?: string };
|
|
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 (!result.workspaceId && m.workspace_id) workspacePreferences.selectWorkspace(m.workspace_id);
|
|
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(t("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(t("Failed to move session."));
|
|
}
|
|
}
|
|
async function newGroup(s: SessionSummary) {
|
|
const name = window.prompt(t("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(t("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(t("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(t("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 ? t("Failed to restore session.") : t("Failed to archive session."));
|
|
}
|
|
}
|
|
}
|
|
|
|
function requestArchiveToggle(session: SessionSummary) {
|
|
if (!session.archived && isForeignSession(session)) {
|
|
const label = session.author ? t("{author}'s session", { author: session.author }) : t("this session");
|
|
if (!window.confirm(t("Archive {label}?", { 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(t("Deleted {deleted} of {total} sessions.", { deleted: deletedIds.size, total: targets.length }));
|
|
}
|
|
} catch {
|
|
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error(t("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={() => {
|
|
if (!canLeaveDatabaseManagement()) return;
|
|
restoredSelection.current = true;
|
|
if (authenticatedUser) rememberSession(authenticatedUser, s.id);
|
|
navigate({ surface: "core" }, true);
|
|
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)) {
|
|
const message = <SessionNotification text={notification.text} />;
|
|
if (notification.level === "error") toast.error(message);
|
|
else if (notification.level === "success") toast.success(message);
|
|
else if (notification.level === "warning") toast.warning(message);
|
|
else toast.info(message);
|
|
}
|
|
|
|
deliveredToastCountRef.current = sessionToasts.length;
|
|
}, [sessionToasts]);
|
|
const sessionViewOpen = Boolean(activeSessionId) || creatingSession;
|
|
const canReturnToCurrentSession = creatingSession || (Boolean(activeSessionId) && !finalized && !activeSession?.archived);
|
|
const sessionActionUnavailable = !canReturnToCurrentSession && (preprocessingBlocksNewSession || !workingContext.ready || administrationBusy);
|
|
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() {
|
|
if (!workingContext.ready || administrationBusy) return;
|
|
if (preprocessingBlocksNewSession) {
|
|
toast.warning(preprocessingQuery.data?.detail ?? t("Workspace preprocessing is required."));
|
|
return;
|
|
}
|
|
if (!canLeaveDatabaseManagement()) return;
|
|
navigate({ surface: "core" }, true);
|
|
invalidateResumeIntent();
|
|
newSessionOperationRef.current = null;
|
|
resetSession();
|
|
// Restore the landing layout, including the right-hand administration rail.
|
|
setPanelSession(null);
|
|
setShowActivity(false);
|
|
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 ? <SessionNotification text={message} /> : t("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() {
|
|
if (!canLeaveDatabaseManagement()) return;
|
|
await logoutUser();
|
|
}
|
|
|
|
const currentNavigation = activeSurface.replace("-management", "");
|
|
const administrationPermitted = activeSurface === "core" || (isAdmin && (
|
|
activeSurface === "workspace-management" ||
|
|
(activeSurface === "database-management" && canManageDatabase) ||
|
|
(activeSurface === "memory-management" && canManageMemory) ||
|
|
(activeSurface === "evidence-management" && canManageEvidence) ||
|
|
(activeSurface === "pi-management" && canManagePi)
|
|
));
|
|
const adminNavigationOpen = adminNavigationValue.includes("administration");
|
|
const managementNavigationCurrent = currentNavigation !== "core";
|
|
|
|
function changeContextWorkspace(id: string) {
|
|
if (contextLocked || !canLeaveDatabaseManagement() || id === workingContext.workspaceId) return;
|
|
invalidateResumeIntent();
|
|
setPanelSession(null);
|
|
selectActiveSession(null);
|
|
resetSession();
|
|
setAwaitingQuestion(false);
|
|
workspacePreferences.selectWorkspace(id);
|
|
}
|
|
function changeContextModel(id: string) {
|
|
if (!contextLocked && canLeaveDatabaseManagement()) workspacePreferences.selectModel(id);
|
|
}
|
|
|
|
return (
|
|
<WorkingContextProvider value={{ workspaceId: workingContext.workspaceId, ready: workingContext.ready, locked: contextLocked, administrationBusy: administrationBusy || (interactionBusy && !agentActive) }}>
|
|
<AdministrationNavigationProvider navigate={navigate}>
|
|
<FullHeader onLogout={canLogout ? signOut : undefined} />
|
|
<div className="thot-context-layout">
|
|
<WorkingContextShelf context={workingContext} locked={contextLocked} onWorkspace={changeContextWorkspace} onModel={changeContextModel} />
|
|
<div
|
|
ref={containerRef}
|
|
style={panelWidthsStyle}
|
|
data-testid="app-shell"
|
|
data-activity-layout={showActivity ? "open" : "closed"}
|
|
data-activity-resizing={activityResizing}
|
|
data-session-resizing={sessionResizing}
|
|
className={[
|
|
"thot-app-shell flex bg-background text-foreground",
|
|
activityResizing || sessionResizing ? "select-none cursor-col-resize" : "",
|
|
].join(" ")}
|
|
>
|
|
{panelSession && (
|
|
<SessionDocumentsPanel
|
|
session={panelSession}
|
|
onClose={() => {
|
|
setPanelSession(null);
|
|
if (authenticatedUser) rememberSession(authenticatedUser, activeSessionId);
|
|
}}
|
|
onResume={doResume}
|
|
desktopSplit={sessionDesktopSplit}
|
|
hidden={activeSurface !== "core"}
|
|
/>
|
|
)}
|
|
{activeSurface === "core" && 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={() => navigate({ surface: "workspace-management" })} hidden={activeSurface !== "core"} />}
|
|
{activeSurface === "core" && 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 */}
|
|
<WorkAreaPanelHost data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
|
|
{!workingContext.ready && <main className="thot-administration-page" aria-label={t("Working context required")}><AdministrationHeader title={t("Choose your working context")} description={t("Select an available workspace and AI model above to enable Core and Administration.")} /></main>}
|
|
{!administrationPermitted && <main className="thot-administration-page" aria-label={t("Administration unavailable")}>
|
|
<AdministrationHeader title={t("Administration unavailable")} description={t("Your account does not have permission to open this page.")} />
|
|
</main>}
|
|
{workingContext.ready && isAdmin && canManageMemory && (activeSurface === "memory-management" || visited.has("memory-management")) && (
|
|
<div style={{ display: activeSurface === "memory-management" ? "contents" : "none" }}><MemoryManagementPage key={selectedWorkspaceId} initialWorkspaceId={selectedWorkspaceId} canManage={canManageMemory} onDirtyChange={setMemoryDirty} onBusyChange={setMemoryBusy} /></div>
|
|
)}
|
|
{workingContext.ready && isAdmin && canManageEvidence && (activeSurface === "evidence-management" || visited.has("evidence-management")) && <div style={{ display: activeSurface === "evidence-management" ? "contents" : "none" }}><EvidenceManagementPage key={selectedWorkspaceId} initialWorkspaceId={selectedWorkspaceId} canManage={canManageEvidence} /></div>}
|
|
{workingContext.ready && isAdmin && canManageDatabase && (activeSurface === "database-management" || visited.has("database-management")) && (
|
|
<div style={{ display: activeSurface === "database-management" ? "contents" : "none" }}>
|
|
<DatabaseManagementPage
|
|
key={selectedWorkspaceId}
|
|
canManage={canManageDatabase}
|
|
canManageSecrets={canManageWorkspaceSecrets}
|
|
onNavigationStateChange={updateDatabaseNavigationState}
|
|
presentation={databaseManagementPresentation}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div
|
|
aria-hidden={activeSurface !== "core" || !workingContext.ready}
|
|
className={activeSurface === "core" && workingContext.ready ? "contents" : "hidden"}
|
|
>
|
|
{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 ? t("Hide model activity") : t("Show model activity")}
|
|
title={showActivity ? t("Hide model activity") : t("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">{t("Question")}</p>
|
|
<h2 className="font-heading text-base 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">{t("Session completed and finalized. The SQL and all phase documents are saved.")}</p>
|
|
<Button className="mt-3" onClick={startNewSession}>{t("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>
|
|
<div style={{ display: activeSurface === "workspace-management" && workingContext.ready ? "contents" : "none" }}><WorkspaceManager
|
|
key={selectedWorkspaceId}
|
|
page
|
|
initialWorkspaceId={selectedWorkspaceId}
|
|
open={workingContext.ready && isAdmin && (activeSurface === "workspace-management" || visited.has("workspace-management"))}
|
|
onClose={() => navigate({ surface: "core" })}
|
|
onNavigationStateChange={updateWorkspaceNavigationState}
|
|
canManageWorkspace={canManageWorkspace}
|
|
canManageSecrets={canManageWorkspaceSecrets}
|
|
canPreprocess={canManageDatabase}
|
|
/></div>
|
|
{isAdmin && canManagePi && (
|
|
<div style={{ display: activeSurface === "pi-management" && workingContext.ready ? "contents" : "none" }}><PiManagement
|
|
page
|
|
open={workingContext.ready && (activeSurface === "pi-management" || visited.has("pi-management"))}
|
|
onClose={() => navigate({ surface: "core" })}
|
|
/></div>
|
|
)}
|
|
</WorkAreaPanelHost>
|
|
|
|
{/* Right session rail */}
|
|
{(
|
|
<ArchiveNavigation surface={activeSurface} forceDrawer={activeSurface === "core" && showActivity}>
|
|
<aside aria-label={t("Session navigation")} className="flex min-h-0 w-64 shrink-0 flex-col bg-sidebar">
|
|
<div className="thot-session-navigation__header relative text-center">
|
|
<h1 className="thot-wordmark--sidebar font-heading text-[2rem] font-semibold leading-none tracking-tight text-foreground">
|
|
Thoth<span className="text-primary">II</span>
|
|
</h1>
|
|
<p className="thot-label mt-1.5">{t("Datamart Builder with")}<br />{t("Human-in-the-loop review")}</p>
|
|
{authenticatedUser && shell.mode === "embedded" && (
|
|
<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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="thot-session-navigation__primary-controls">
|
|
<Button
|
|
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
|
|
size="sm"
|
|
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
|
aria-current={currentNavigation === "core" ? "page" : undefined}
|
|
data-navigation-state={sessionActionUnavailable
|
|
? "unavailable"
|
|
: currentNavigation === "core" ? "current" : "available"}
|
|
disabled={sessionActionUnavailable}
|
|
title={sessionActionUnavailable && preprocessingBlocksNewSession ? preprocessingQuery.data?.detail : undefined}
|
|
onClick={() => {
|
|
if (canReturnToCurrentSession) navigate({ surface: "core" });
|
|
else startNewSession();
|
|
}}
|
|
>{t("Session")}</Button>
|
|
{isAdmin && (
|
|
<Accordion.Root
|
|
value={adminNavigationValue}
|
|
onValueChange={setAdminNavigationValue}
|
|
data-testid="administration-boundary"
|
|
className="min-w-0 max-w-full rounded-lg border border-border/80 bg-card/45 p-1.5"
|
|
>
|
|
<Accordion.Item value="administration" className="min-w-0 max-w-full">
|
|
<Accordion.Header className="m-0 min-w-0 max-w-full">
|
|
<Accordion.Trigger
|
|
className={buttonVariants({
|
|
variant: !adminNavigationOpen && managementNavigationCurrent ? "navigationActive" : "outline",
|
|
size: "sm",
|
|
className: "w-full min-w-0 max-w-full justify-between px-2.5",
|
|
})}
|
|
data-navigation-state={!adminNavigationOpen && managementNavigationCurrent ? "current" : "available"}
|
|
>
|
|
<span>{t("Administration")}</span>
|
|
<ChevronDown
|
|
aria-hidden="true"
|
|
data-icon="inline-end"
|
|
className={`transition-transform duration-150 motion-reduce:transition-none ${adminNavigationOpen ? "rotate-180" : ""}`}
|
|
/>
|
|
</Accordion.Trigger>
|
|
</Accordion.Header>
|
|
<Accordion.Panel className="grid min-w-0 max-w-full gap-1.5 px-0.5 pt-1.5">
|
|
<Button
|
|
variant={currentNavigation === "database" ? "navigationActive" : "outline"}
|
|
size="sm"
|
|
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
|
aria-current={currentNavigation === "database" ? "page" : undefined}
|
|
data-navigation-state={currentNavigation === "database" ? "current" : canManageDatabase ? "available" : "unavailable"}
|
|
disabled={!canManageDatabase}
|
|
title={canManageDatabase ? undefined : t("Database management permission is required")}
|
|
onClick={() => {
|
|
if (!canLeaveDatabaseManagement()) return;
|
|
navigate({ surface: "database-management" }, true);
|
|
}}
|
|
>{t("Database")}</Button>
|
|
<Button
|
|
variant={currentNavigation === "memory" ? "navigationActive" : "outline"}
|
|
size="sm"
|
|
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
|
aria-current={currentNavigation === "memory" ? "page" : undefined}
|
|
disabled={!canManageMemory}
|
|
title={canManageMemory ? undefined : t("Memory management permission is required")}
|
|
data-navigation-state={currentNavigation === "memory" ? "current" : "available"}
|
|
onClick={() => {
|
|
if (!canLeaveDatabaseManagement()) return;
|
|
navigate({ surface: "memory-management" }, true);
|
|
}}
|
|
>{t("Memory")}</Button>
|
|
<Button variant={currentNavigation === "evidence" ? "navigationActive" : "outline"}
|
|
size="sm" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
|
aria-current={currentNavigation === "evidence" ? "page" : undefined}
|
|
disabled={!canManageEvidence} title={canManageEvidence ? undefined : t("Evidence management permission is required")}
|
|
onClick={() => navigate({ surface: "evidence-management" })}
|
|
>{t("Evidence")}</Button>
|
|
<div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" />
|
|
<Button
|
|
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
|
|
size="sm"
|
|
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
|
aria-current={currentNavigation === "workspace" ? "page" : undefined}
|
|
aria-label={t("Workspace")}
|
|
aria-describedby="workspace-readiness-indicator"
|
|
title={workspaceReadinessLabel}
|
|
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
|
|
onClick={() => {
|
|
if (!canLeaveDatabaseManagement()) return;
|
|
navigate({ surface: "workspace-management" }, true);
|
|
}}
|
|
>{t("Workspace")}<span id="workspace-readiness-indicator" role="img" aria-label={workspaceReadinessLabel}
|
|
data-ready={workspaceReady} className={`ml-1 size-2 shrink-0 rounded-full ${workspaceReady ? "bg-[oklch(var(--success))]" : "bg-destructive"}`} /></Button>
|
|
<Button
|
|
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
|
|
size="sm"
|
|
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
|
aria-current={currentNavigation === "pi" ? "page" : undefined}
|
|
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
|
|
disabled={!canManagePi}
|
|
title={canManagePi ? undefined : t("Pi management permission is required")}
|
|
onClick={() => {
|
|
if (!canLeaveDatabaseManagement()) return;
|
|
navigate({ surface: "pi-management" }, true);
|
|
}}
|
|
>{t("Pi configuration")}</Button>
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
</Accordion.Root>
|
|
)}
|
|
</div>
|
|
|
|
{canReadAllSessions && (
|
|
<div className="px-4">
|
|
<div
|
|
role="tablist"
|
|
aria-label={t("Session scope")}
|
|
aria-orientation="horizontal"
|
|
className="grid grid-cols-2 gap-1"
|
|
>
|
|
<button
|
|
ref={(node) => { sessionScopeTabRefs.current.mine = node; }}
|
|
id="session-scope-mine-tab"
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={sessionScope === "mine"}
|
|
aria-controls="session-scope-panel"
|
|
tabIndex={sessionScope === "mine" ? 0 : -1}
|
|
data-tab-state={sessionScope === "mine" ? "active" : "inactive"}
|
|
className={sessionScopeTabClass(sessionScope === "mine")}
|
|
onClick={() => setSessionScope("mine")}
|
|
onKeyDown={(event) => handleSessionScopeTabKeyDown(event, "mine")}
|
|
>{t("My sessions")}</button>
|
|
<button
|
|
ref={(node) => { sessionScopeTabRefs.current.all = node; }}
|
|
id="session-scope-all-tab"
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={showingAllSessions}
|
|
aria-controls="session-scope-panel"
|
|
tabIndex={showingAllSessions ? 0 : -1}
|
|
data-tab-state={showingAllSessions ? "active" : "inactive"}
|
|
className={sessionScopeTabClass(showingAllSessions)}
|
|
onClick={() => setSessionScope("all")}
|
|
onKeyDown={(event) => handleSessionScopeTabKeyDown(event, "all")}
|
|
>{t("All sessions")}</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
id={canReadAllSessions ? "session-scope-panel" : undefined}
|
|
role={canReadAllSessions ? "tabpanel" : undefined}
|
|
aria-labelledby={canReadAllSessions ? `session-scope-${sessionScope}-tab` : undefined}
|
|
className="flex min-h-0 flex-1 flex-col"
|
|
>
|
|
{canReadAllSessions && showingAllSessions && (
|
|
<p className="sr-only">
|
|
{t("Administrator view: all sessions")}
|
|
</p>
|
|
)}
|
|
|
|
<Accordion.Root multiple={false}
|
|
value={[...(activeOpen ? ["active"] : []), ...(archiveOpen ? ["archive"] : [])]}
|
|
onValueChange={value => { setActiveOpen(value.includes("active")); setArchiveOpen(value.includes("archive")); }}
|
|
className="thot-session-accordion min-h-0 flex-1 overflow-y-auto px-2 pb-4">
|
|
<Accordion.Item value="active" className="thot-session-accordion__item">
|
|
<Accordion.Header className="m-0">
|
|
<Accordion.Trigger className="thot-session-accordion__trigger">
|
|
<span>{t("Active sessions")}</span>
|
|
<ChevronDown aria-hidden="true" className={`size-4 shrink-0 ${activeOpen ? "rotate-180" : ""}`} />
|
|
</Accordion.Trigger>
|
|
</Accordion.Header>
|
|
<Accordion.Panel className="thot-session-accordion__panel" tabIndex={0}>
|
|
{sessionSelectionControls(activeList)}
|
|
<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={t("Rename group {name}", { name: 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 its empty state. */}
|
|
{(ungroupedActive.length > 0 || groups.length === 0) && (
|
|
<NavSessions
|
|
sessions={ungroupedActive}
|
|
activeSessionId={activeSessionId}
|
|
onOpenPanel={openPanel}
|
|
menuFor={menuFor}
|
|
selectedIds={selectedSessionIds}
|
|
onSelectionChange={setSessionSelected}
|
|
showOwner={showingAllSessions}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
<Accordion.Item value="archive" className="thot-session-accordion__item">
|
|
<Accordion.Header className="m-0">
|
|
<Accordion.Trigger className="thot-session-accordion__trigger">
|
|
<span className="tabular-nums">{t("Archive ({count})", { count: archivedList.length })}</span>
|
|
<ChevronDown aria-hidden="true" className={`size-4 shrink-0 ${archiveOpen ? "rotate-180" : ""}`} />
|
|
</Accordion.Trigger>
|
|
</Accordion.Header>
|
|
<Accordion.Panel className="thot-session-accordion__panel" tabIndex={0}>
|
|
{sessionSelectionControls(archivedList)}
|
|
<NavSessions
|
|
sessions={archivedList}
|
|
activeSessionId={activeSessionId}
|
|
onOpenPanel={openPanel}
|
|
menuFor={menuFor}
|
|
selectedIds={selectedSessionIds}
|
|
onSelectionChange={setSessionSelected}
|
|
showOwner={showingAllSessions}
|
|
/>
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
</Accordion.Root>
|
|
</div>
|
|
</aside>
|
|
</ArchiveNavigation>
|
|
)}
|
|
<Toaster theme={shell.theme} />
|
|
|
|
<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={t("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>
|
|
</div>
|
|
</AdministrationNavigationProvider>
|
|
</WorkingContextProvider>
|
|
);
|
|
}
|
|
|
|
function EmptyState() {
|
|
const { t } = useI18n();
|
|
return (
|
|
<div className="flex min-h-[72vh] flex-col items-center justify-center text-center">
|
|
<span className="thot-wordmark--core 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-base leading-relaxed text-muted-foreground">
|
|
{t("Type your question below. Review each step before creating your datamart.")}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|