feat: establish unified administration and model context baseline

This commit is contained in:
Codex
2026-09-12 18:03:15 +02:00
parent 840344706f
commit f52bf22e05
74 changed files with 2334 additions and 523 deletions
+121 -81
View File
@@ -20,7 +20,6 @@ import { DatabaseManagementPage } from "./DatabaseManagementPage";
import { MemoryManagementPage } from "./MemoryManagementPage";
import { EvidenceManagementPage } from "./EvidenceManagementPage";
import { ArchiveNavigation } from "./ArchiveNavigation";
import { WorkspacePreprocessingControl } from "./WorkspacePreprocessingControl";
import { Pencil, ArrowLeft, ArrowRight, ChevronDown, Trash2 } from "lucide-react";
import { Accordion } from "@base-ui/react/accordion";
import { Button, buttonVariants } from "../components/ui/button";
@@ -37,20 +36,23 @@ import {
} from "../components/ui/dialog";
import type { SessionScope, SessionSummary } from "../api/types";
import { useAuthGeneration, useAuthUser } from "../auth/authState";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { 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, type WorkspacePreference } from "../workspaces/preferences";
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";
interface AppShellProps {
canLogout: boolean;
}
type ActiveSurface = "core" | "database-management" | "memory-management" | "evidence-management";
type ActiveManagementPanel = "workspace" | "pi" | null;
const SESSION_SCOPES: readonly SessionScope[] = ["mine", "all"];
function sessionScopeTabClass(selected: boolean): string {
@@ -89,6 +91,10 @@ export function resolveDatabaseManagementPresentation({
}
export function AppShell({ canLogout }: AppShellProps) {
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 {
@@ -166,18 +172,9 @@ export function AppShell({ canLogout }: AppShellProps) {
queryKey: ["settings"],
queryFn: getSettings,
});
const [workspacePreference, setWorkspacePreference] = useState<WorkspacePreference>(
() => workspacePreferences.load(),
);
useEffect(() => {
const unsubscribe = workspacePreferences.subscribe(setWorkspacePreference);
// Child effects may seed the singleton before this parent effect subscribes.
setWorkspacePreference(workspacePreferences.load());
return unsubscribe;
}, []);
// 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 = workspacePreference.workspaceId;
const selectedWorkspaceId = workingContext.workspace?.id;
const preprocessingQuery = useQuery({
queryKey: ["workspace-preprocessing", selectedWorkspaceId],
queryFn: () => getWorkspacePreprocessingStatus(selectedWorkspaceId!),
@@ -204,7 +201,6 @@ export function AppShell({ canLogout }: AppShellProps) {
const queryClient = useQueryClient();
const [showActivity, setShowActivity] = useState(false);
const [activeSurface, setActiveSurface] = useState<ActiveSurface>("core");
const databaseManagementPresentation = resolveDatabaseManagementPresentation({
isDevelopment: import.meta.env.DEV,
mode: import.meta.env.MODE,
@@ -214,8 +210,25 @@ export function AppShell({ canLogout }: AppShellProps) {
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 [activeManagementPanel, setActiveManagementPanel] = useState<ActiveManagementPanel>(null);
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);
@@ -306,30 +319,22 @@ export function AppShell({ canLogout }: AppShellProps) {
}
function canLeaveDatabaseManagement(): boolean {
if (activeSurface === "memory-management" && memoryBusy) {
toast.info("Wait for the Memory operation to finish before leaving this page");
const dirty = activeSurface === "workspace-management" ? workspaceNavigationRef.current.dirty
: activeSurface === "memory-management" ? memoryDirty
: activeSurface === "database-management" ? databaseNavigationRef.current.dirty : false;
if (dirty) {
toast.warning("Save your administration changes, or cancel the edit, before leaving this page.");
return false;
}
if (activeSurface === "memory-management" && memoryDirty) {
return window.confirm("Discard unsaved Memory changes and leave Memory management?");
}
if (activeSurface !== "database-management") return true;
if (databaseNavigationRef.current.busy) {
toast.info("Wait for the database operation to finish before leaving this page");
return false;
}
if (databaseNavigationRef.current.dirty) {
return window.confirm("Discard unsaved database changes and leave database management?");
}
return true;
}
function openPanel(id: string) {
if (!workingContext.ready) return;
if (!canLeaveDatabaseManagement()) return;
const s = sessions.find((x) => x.id === id);
if (!s) return;
setActiveSurface("core");
setActiveManagementPanel(null);
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
@@ -349,9 +354,9 @@ export function AppShell({ canLogout }: AppShellProps) {
});
}
async function doResume(id: string, databaseExitApproved = false) {
if (!workingContext.ready) return;
if (!databaseExitApproved && !canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel(null);
navigate({ surface: "core" }, true);
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
const token = ++resumeInvocationRef.current;
@@ -388,6 +393,9 @@ export function AppShell({ canLogout }: AppShellProps) {
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);
@@ -417,7 +425,7 @@ export function AppShell({ canLogout }: AppShellProps) {
// Paint the persisted re-entry phase while the replacement stream starts replaying.
// The manifest's `phase` is the 1-based current phase (1..8).
try {
const m = (await getSession(id)) as { phase?: number };
const m = (await getSession(id)) as { phase?: number; workspace_id?: string };
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
const latestAfterManifest = latestResumeIntentRef.current;
if (
@@ -425,6 +433,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|| 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}`);
}
@@ -557,7 +566,7 @@ export function AppShell({ canLogout }: AppShellProps) {
session={s}
groups={groups}
onResume={() => doResume(s.id)}
onView={() => setPanelSession(s)}
onView={() => { if (canLeaveDatabaseManagement()) { navigate({ surface: "core" }, true); setPanelSession(s); } }}
onRename={() => setRenameTarget(s)}
onMove={(g) => move(s, g)}
onNewGroup={() => newGroup(s)}
@@ -633,13 +642,13 @@ export function AppShell({ canLogout }: AppShellProps) {
}, [lastSystemEvent]);
function startNewSession() {
if (!workingContext.ready || administrationBusy) return;
if (preprocessingBlocksNewSession) {
toast.warning(preprocessingQuery.data?.detail ?? "Workspace preprocessing is required.");
return;
}
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel(null);
navigate({ surface: "core" }, true);
invalidateResumeIntent();
newSessionOperationRef.current = null;
resetSession();
@@ -707,13 +716,35 @@ export function AppShell({ canLogout }: AppShellProps) {
await logoutUser();
}
const currentNavigation = activeSurface === "database-management"
? "database"
: activeSurface === "memory-management" ? "memory" : activeSurface === "evidence-management" ? "evidence" : activeManagementPanel ?? "core";
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}>
<div className="thot-context-layout">
<WorkingContextShelf context={workingContext} locked={contextLocked} onWorkspace={changeContextWorkspace} onModel={changeContextModel} />
<div
ref={containerRef}
style={panelWidthsStyle}
@@ -722,8 +753,7 @@ export function AppShell({ canLogout }: AppShellProps) {
data-activity-resizing={activityResizing}
data-session-resizing={sessionResizing}
className={[
"flex h-screen bg-background text-foreground",
["memory-management", "evidence-management"].includes(activeSurface) ? "max-md:pt-12" : "",
"thot-app-shell flex bg-background text-foreground",
activityResizing || sessionResizing ? "select-none cursor-col-resize" : "",
].join(" ")}
>
@@ -748,7 +778,7 @@ export function AppShell({ canLogout }: AppShellProps) {
</div>
</div>
)}
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} onOpenWorkspaceManager={() => setActiveManagementPanel("workspace")} hidden={activeSurface !== "core"} />}
{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
@@ -764,21 +794,29 @@ export function AppShell({ canLogout }: AppShellProps) {
{/* Conversation column */}
<WorkAreaPanelHost data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
{activeSurface === "memory-management" && (
<MemoryManagementPage canManage={canManageMemory} onDirtyChange={setMemoryDirty} onBusyChange={setMemoryBusy} />
{!workingContext.ready && <main className="thot-administration-page" aria-label="Working context required"><AdministrationHeader title="Choose your working context" description="Select an available workspace and AI model above to enable Core and Administration." /></main>}
{!administrationPermitted && <main className="thot-administration-page" aria-label="Administration unavailable">
<AdministrationHeader title="Administration unavailable" description="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>
)}
{activeSurface === "evidence-management" && <EvidenceManagementPage canManage={canManageEvidence} />}
{activeSurface === "database-management" && (
{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}
initialWorkspaceId={selectedWorkspaceId}
canManage={canManageDatabase}
canManageSecrets={canManageWorkspaceSecrets}
onNavigationStateChange={updateDatabaseNavigationState}
presentation={databaseManagementPresentation}
/>
</div>
)}
<div
aria-hidden={activeSurface !== "core"}
className={activeSurface === "core" ? "contents" : "hidden"}
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">
@@ -857,23 +895,29 @@ export function AppShell({ canLogout }: AppShellProps) {
</div>
</div>
</div>
<WorkspaceManager
open={activeManagementPanel === "workspace"}
onClose={() => setActiveManagementPanel(null)}
<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}
/>
{canManagePi && (
<PiManagement
open={activeManagementPanel === "pi"}
onClose={() => setActiveManagementPanel(null)}
/>
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 */}
{(activeSurface !== "core" || !showActivity) && (
<ArchiveNavigation surface={activeSurface}>
{(
<ArchiveNavigation surface={activeSurface} forceDrawer={activeSurface === "core" && showActivity}>
<aside aria-label="Session navigation" className="flex w-64 shrink-0 flex-col bg-sidebar">
<div className="thot-session-navigation__header relative text-center">
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
@@ -899,15 +943,18 @@ export function AppShell({ canLogout }: AppShellProps) {
</div>
<div className="thot-session-navigation__primary-controls">
{activeSurface !== "core" && <Button variant="outline" size="sm" className="w-full" onClick={() => navigate({ surface: "core" })}>
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
data-navigation-state={preprocessingBlocksNewSession || !workingContext.ready || administrationBusy
? "unavailable"
: currentNavigation === "core" ? "current" : "available"}
disabled={preprocessingBlocksNewSession}
disabled={preprocessingBlocksNewSession || !workingContext.ready || administrationBusy}
title={preprocessingBlocksNewSession ? preprocessingQuery.data?.detail : undefined}
onClick={startNewSession}
>
@@ -949,8 +996,7 @@ export function AppShell({ canLogout }: AppShellProps) {
title={canManageDatabase ? undefined : "Database management permission is required"}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveManagementPanel(null);
setActiveSurface("database-management");
navigate({ surface: "database-management" }, true);
}}
>
Database management
@@ -965,15 +1011,14 @@ export function AppShell({ canLogout }: AppShellProps) {
data-navigation-state={currentNavigation === "memory" ? "current" : "available"}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveManagementPanel(null);
setActiveSurface("memory-management");
navigate({ surface: "memory-management" }, true);
}}
>Memory management</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 : "Evidence management permission is required"}
onClick={() => { if (!canLeaveDatabaseManagement()) return; setActiveManagementPanel(null); setActiveSurface("evidence-management"); }}
onClick={() => navigate({ surface: "evidence-management" })}
>Evidence management</Button>
<div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" />
<Button
@@ -981,12 +1026,10 @@ export function AppShell({ canLogout }: AppShellProps) {
size="sm"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "workspace" ? "page" : undefined}
aria-expanded={activeManagementPanel === "workspace"}
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel("workspace");
navigate({ surface: "workspace-management" }, true);
}}
>
Workspace management
@@ -996,25 +1039,19 @@ export function AppShell({ canLogout }: AppShellProps) {
size="sm"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "pi" ? "page" : undefined}
aria-expanded={activeManagementPanel === "pi"}
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
disabled={!canManagePi}
title={canManagePi ? undefined : "Pi management permission is required"}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel("pi");
navigate({ surface: "pi-management" }, true);
}}
>
Pi management
</Button>
<WorkspacePreprocessingControl
workspaceId={selectedWorkspaceId}
status={preprocessingQuery.data}
loading={preprocessingQuery.isLoading}
unavailable={preprocessingQuery.isError}
canManage={canManageDatabase}
/>
<Button variant="ghost" size="sm" className="w-full min-w-0 whitespace-normal" onClick={() => navigate({ surface: "workspace-management", workspace: selectedWorkspaceId })}>
Workspace readiness: {preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking"}
</Button>
</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
@@ -1241,6 +1278,9 @@ export function AppShell({ canLogout }: AppShellProps) {
/>
)}
</div>
</div>
</AdministrationNavigationProvider>
</WorkingContextProvider>
);
}