1307 lines
62 KiB
TypeScript
1307 lines
62 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 -mb-px flex h-8 w-full cursor-pointer items-center justify-center rounded-t-md border border-b-0 px-2 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 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 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 { 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(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 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 toggleAllSessions(selected: boolean) {
|
|
setSelectedSessionIds(selected ? new Set(sessions.map((session) => session.id)) : new Set());
|
|
}
|
|
|
|
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 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();
|
|
// 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 ? <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 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">
|
|
{activeSurface !== "core" && <Button variant="outline" size="sm" className="w-full" onClick={() => navigate({ surface: "core" })}>{t("Return to session")}</Button>}
|
|
<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={preprocessingBlocksNewSession || !workingContext.ready || administrationBusy
|
|
? "unavailable"
|
|
: currentNavigation === "core" ? "current" : "available"}
|
|
disabled={preprocessingBlocksNewSession || !workingContext.ready || administrationBusy}
|
|
title={preprocessingBlocksNewSession ? preprocessingQuery.data?.detail : undefined}
|
|
onClick={startNewSession}
|
|
>{t("New 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}
|
|
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
|
|
onClick={() => {
|
|
if (!canLeaveDatabaseManagement()) return;
|
|
navigate({ surface: "workspace-management" }, true);
|
|
}}
|
|
>{t("Workspace")}</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>
|
|
<Button variant="ghost" size="sm" className="w-full min-w-0 whitespace-normal" onClick={() => navigate({ surface: "workspace-management", workspace: selectedWorkspaceId })}>{t("Workspace readiness: {state}", { state: t(preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking") })}
|
|
</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 border-b border-border"
|
|
>
|
|
<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="mx-4 mb-2 mt-2 text-xs font-medium text-muted-foreground">
|
|
{t("Administrator view: all sessions")}
|
|
</p>
|
|
)}
|
|
|
|
{/* L1 — rail title */}
|
|
<div className="px-4 pb-1.5 pt-1">
|
|
<span className="text-sm font-semibold text-foreground">
|
|
{t("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={t("Select all sessions")}
|
|
disabled={sessions.length === 0}
|
|
onCheckedChange={(selected) => toggleAllSessions(selected === true)}
|
|
/>
|
|
<span>{t("Select all")}</span>
|
|
</label>
|
|
{selectedSessions.length > 0 && (
|
|
<Button
|
|
variant="destructive"
|
|
size="xs"
|
|
aria-label={t("Delete {count} selected sessions", { count: selectedSessions.length })}
|
|
onClick={() => {
|
|
requestDelete(selectedSessions);
|
|
}}
|
|
>
|
|
<Trash2 />
|
|
{t("Delete ({count})", { count: 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>{t("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={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 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">{t("Archive ({count})", { count: archivedList.length })}</span>
|
|
</button>
|
|
{archiveOpen && (
|
|
<NavSessions
|
|
sessions={archivedList}
|
|
activeSessionId={activeSessionId}
|
|
onOpenPanel={openPanel}
|
|
menuFor={menuFor}
|
|
selectedIds={selectedSessionIds}
|
|
onSelectionChange={setSessionSelected}
|
|
showOwner={showingAllSessions}
|
|
/>
|
|
)}
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|