fix: address administration layout and navigation issues #28 #29 #30 #31

This commit is contained in:
Codex
2026-09-12 18:16:52 +02:00
parent f52bf22e05
commit c7e5f295e6
25 changed files with 286 additions and 90 deletions
@@ -439,12 +439,12 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
await administration.click();
await expect(administration).toHaveAttribute("aria-expanded", "false");
await expect(adminNavigationRail.getByRole("button", { name: "Database management", exact: true })).toHaveCount(0);
await expect(adminNavigationRail.getByRole("button", { name: "Database", exact: true })).toHaveCount(0);
await administration.click();
await expect(administration).toHaveAttribute("aria-expanded", "true");
await adminNavigationRail
.getByRole("button", { name: "Database management", exact: true })
.getByRole("button", { name: "Database", exact: true })
.click();
await expectCurrentNavigation(page, "Database management");
@@ -795,7 +795,7 @@ test("Workspace and Pi management share the centered work-area panel without cov
await expectWorkAreaPanelGeometry(page, "Workspace management");
await expectWorkAreaPanelHeaderUsesPrimary(page, "Workspace management");
const piTrigger = page.getByRole("button", { name: "Pi management", exact: true });
const piTrigger = page.getByRole("button", { name: "Pi configuration", exact: true });
await piTrigger.click();
await expectCurrentNavigation(page, "Pi management");
await expect(
@@ -812,7 +812,7 @@ test("Workspace and Pi management share the centered work-area panel without cov
}
await page.setViewportSize({ width: 720, height: 800 });
await page.getByRole("button", { name: "Workspace management", exact: true }).click();
await page.getByRole("button", { name: "Workspace", exact: true }).click();
await expectCurrentNavigation(page, "Workspace management");
const compactWorkArea = page.getByTestId("conversation-column");
const compactPanel = compactWorkArea.getByRole("dialog", { name: "Workspace management" });
+5 -5
View File
@@ -112,7 +112,7 @@ LocalEvidenceArchive(root).initialize()
}).allTextContents()).map(text => text.trim())).toEqual([
"Database management", "Memory management", "Evidence management",
]);
await page.getByRole("button", { name: "Memory management", exact: true }).click();
await page.getByRole("button", { name: "Memory", exact: true }).click();
await page.screenshot({ path: "/private/tmp/thothii-m1-memory-opening.png", fullPage: true });
const memory = page.getByRole("main", { name: "Memory management" });
await expect(memory).toBeVisible();
@@ -157,9 +157,9 @@ finally: service.close()
THT_PRINCIPAL_ISSUER: "test", THT_PRINCIPAL_SUBJECT: "admin", THT_PRINCIPAL_IS_ADMIN: "true" });
expect(JSON.parse(recalled)).toEqual([]);
await expect(page.getByRole("button", { name: "New card", exact: true })).toBeEnabled();
if (!await page.getByRole("button", { name: "Evidence management", exact: true }).isVisible())
if (!await page.getByRole("button", { name: "Evidence", exact: true }).isVisible())
await page.getByRole("button", { name: "Administration", exact: true }).click();
await page.getByRole("button", { name: "Evidence management", exact: true }).click();
await page.getByRole("button", { name: "Evidence", exact: true }).click();
const evidence = page.getByRole("main", { name: "Evidence management" });
await expect(evidence).toBeVisible();
await evidence.getByRole("combobox", { name: /Workspace/ }).selectOption("fixture-workspace");
@@ -179,12 +179,12 @@ finally: service.close()
await page.screenshot({ path: "/private/tmp/thothii-acceptance-evidence-mobile.png", fullPage: true });
await page.getByRole("button", { name: "Navigation", exact: true }).click();
const navigation = page.getByRole("dialog", { name: "Navigation", exact: true });
await expect(navigation.getByRole("button", { name: "Memory management", exact: true })).toBeVisible();
await expect(navigation.getByRole("button", { name: "Memory", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(navigation).toBeHidden();
await expect(page.getByRole("button", { name: "Navigation", exact: true })).toBeFocused();
await page.getByRole("button", { name: "Navigation", exact: true }).click();
await navigation.getByRole("button", { name: "Memory management", exact: true }).click();
await navigation.getByRole("button", { name: "Memory", exact: true }).click();
await expect(navigation).toBeHidden();
const mobileMemory = page.getByRole("main", { name: "Memory management" });
await expect(mobileMemory).toBeVisible();
+1
View File
@@ -9,6 +9,7 @@ export interface PiInstallationConfig {
}
export interface PiManagementStatus {
hostPlatform?: "linux" | "macos" | "windows";
version?: string;
ready: boolean;
credentials: "present" | "missing";
@@ -39,6 +39,17 @@ beforeEach(() => {
});
afterEach(() => vi.restoreAllMocks());
test("uses short Admin buttons while keeping management in page titles", async () => {
renderShell();
await screen.findByRole("textbox", { name: /new question/i });
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
for (const [label, title] of [["Workspace", "Workspace"], ["Evidence", "Evidence"], ["Memory", "Memory"], ["Database", "Database"], ["Pi configuration", "Pi"]]) {
await userEvent.click(screen.getByRole("button", { name: label }));
expect(await screen.findByRole("main", { name: `${title} management` })).toBeVisible();
expect(screen.queryByRole("button", { name: `${title} management` })).not.toBeInTheDocument();
}
});
test.each(["Workspace", "Evidence", "Memory", "Database", "Pi"])("reloads the %s route as a full page", async name => {
window.history.replaceState({ portal: "kept" }, "", `/portal?lang=it&thoth_route=administration/${name.toLowerCase()}#host`);
renderShell();
@@ -52,8 +63,8 @@ test("preserves host state and query parameters through Back and Forward", async
window.history.replaceState({ portal: { view: "thoth" } }, "", "/portal?lang=it#host");
renderShell();
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
await userEvent.click(screen.getByRole("button", { name: "Pi management" }));
await userEvent.click(screen.getByRole("button", { name: "Workspace" }));
await userEvent.click(screen.getByRole("button", { name: "Pi configuration" }));
act(() => window.history.back());
expect(await screen.findByRole("main", { name: "Workspace management" })).toBeVisible();
act(() => window.history.forward());
@@ -75,8 +86,8 @@ test.each(["workspace", "database", "memory", "evidence", "pi"])("denies a direc
test("Back and Core navigation preserve a Memory draft until the editor cancels it", async () => {
renderShell();
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
await userEvent.click(screen.getByRole("button", { name: "Memory management" }));
await userEvent.click(screen.getByRole("button", { name: "Workspace" }));
await userEvent.click(screen.getByRole("button", { name: "Memory" }));
const page = await screen.findByRole("main", { name: "Memory management" });
await userEvent.click(await within(page).findByRole("button", { name: "New card" }));
await userEvent.type(within(page).getByLabelText("Title"), "Keep this draft");
@@ -122,7 +133,7 @@ test("option A has one collapsible global context and preserves the Core draft a
await userEvent.click(screen.getByRole("button", { name: "Done" }));
expect(screen.queryByRole("combobox", { name: "Model" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
await userEvent.click(screen.getByRole("button", { name: "Memory management" }));
await userEvent.click(screen.getByRole("button", { name: "Memory" }));
expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible();
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
+12 -12
View File
@@ -52,7 +52,7 @@ describe("authenticated shell permissions", () => {
renderShell({ subject: "admin", isAdmin: true, roles: ["admin"],
permissions: ["session.use", "memory.manage", "database.manage"] });
await userEvent.click(await screen.findByRole("button", { name: "Administration" }));
await userEvent.click(screen.getByRole("button", { name: "Memory management" }));
await userEvent.click(screen.getByRole("button", { name: "Memory" }));
const memory = screen.getByRole("main", { name: "Memory management" });
expect(memory).toBeVisible();
expect(within(memory).queryByRole("combobox", { name: "Workspace" })).not.toBeInTheDocument();
@@ -111,9 +111,9 @@ describe("authenticated shell permissions", () => {
expect(await screen.findByRole("button", { name: "New session" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Database management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Database" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi configuration" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "All sessions" })).not.toBeInTheDocument();
});
@@ -137,12 +137,12 @@ describe("authenticated shell permissions", () => {
expect(trigger).toHaveAttribute("aria-expanded", "true");
const panel = within(sessionNavigation).getByRole("region", { name: "Administration" });
const database = within(panel).getByRole("button", { name: "Database management" });
const memory = within(panel).getByRole("button", { name: "Memory management" });
const evidence = within(panel).getByRole("button", { name: "Evidence management" });
const database = within(panel).getByRole("button", { name: "Database" });
const memory = within(panel).getByRole("button", { name: "Memory" });
const evidence = within(panel).getByRole("button", { name: "Evidence" });
const separator = within(panel).getByRole("separator");
const workspace = within(panel).getByRole("button", { name: "Workspace management" });
const pi = within(panel).getByRole("button", { name: "Pi management" });
const workspace = within(panel).getByRole("button", { name: "Workspace" });
const pi = within(panel).getByRole("button", { name: "Pi configuration" });
expect(panel.children[0]).toBe(database);
expect(panel.children[1]).toBe(memory);
expect(panel.children[2]).toBe(evidence);
@@ -156,7 +156,7 @@ describe("authenticated shell permissions", () => {
await user.keyboard(" ");
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(within(sessionNavigation).queryByRole("region", { name: "Administration" })).not.toBeInTheDocument();
expect(within(sessionNavigation).queryByRole("button", { name: "Database management" })).not.toBeInTheDocument();
expect(within(sessionNavigation).queryByRole("button", { name: "Database" })).not.toBeInTheDocument();
});
test("keeps identity but hides logout outside local authentication", async () => {
@@ -277,8 +277,8 @@ describe("authenticated shell permissions", () => {
act(() => clearAuthState());
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi configuration" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "All sessions" })).not.toBeInTheDocument();
});
@@ -107,7 +107,7 @@ test("starts on the core activity after a hard refresh even if metadata manageme
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
expect(within(sessionNavigation).getByRole("button", { name: "New session" }))
.toHaveAttribute("aria-current", "page");
expect(within(sessionNavigation).getByRole("button", { name: "Database management" }))
expect(within(sessionNavigation).getByRole("button", { name: "Database" }))
.not.toHaveAttribute("aria-current");
expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument();
});
@@ -119,7 +119,7 @@ test("replaces the core conversation while keeping the session navigation", asyn
const conversationColumn = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
const databaseManagement = within(sessionNavigation).getByRole("button", { name: "Database management" });
const databaseManagement = within(sessionNavigation).getByRole("button", { name: "Database" });
expect(newSession).toHaveAttribute("aria-current", "page");
expect(newSession).toHaveAttribute("data-navigation-state", "current");
@@ -178,7 +178,7 @@ test("keeps a live core session connected while returning from database manageme
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
const source = FakeEventSource.instances[0];
await userEvent.click(screen.getByRole("button", { name: "Database management" }));
await userEvent.click(screen.getByRole("button", { name: "Database" }));
expect(screen.getByRole("main", { name: "Database management" })).toBeVisible();
expect(source.closed).toBe(false);
@@ -210,9 +210,9 @@ test("hides the complete administrative navigation from a regular user", () => {
expect(screen.getByRole("button", { name: "New session" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Database management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Database" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi configuration" })).not.toBeInTheDocument();
});
test("requires saving or cancelling a dirty database form before leaving", async () => {
@@ -250,7 +250,8 @@ test("requires saving or cancelling a dirty database form before leaving", async
renderShell();
await expandAdministration();
await userEvent.click(screen.getByRole("button", { name: "Database management" }));
await userEvent.click(screen.getByRole("button", { name: "Database" }));
await userEvent.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
const schema = await screen.findByLabelText("Schema");
await userEvent.clear(schema);
await userEvent.type(schema, "reporting");
@@ -204,13 +204,13 @@ test("opens Workspace management from the right sidebar without interrupting the
renderShell();
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
await userEvent.click(screen.getByRole("button", { name: "Workspace" }));
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
const dialog = screen.getByRole("main", { name: "Workspace management" });
const workArea = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
const workspaceManagement = within(sessionNavigation).getByRole("button", { name: "Workspace management" });
const workspaceManagement = within(sessionNavigation).getByRole("button", { name: "Workspace" });
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
expect(workArea).toContainElement(dialog);
expect(sessionNavigation).not.toContainElement(dialog);
@@ -122,12 +122,12 @@ test("Pi management preserves the open session summary and the model activity ti
await user.click(await screen.findByText("Attiva uno"));
expect(await screen.findByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
await user.click(screen.getByRole("button", { name: "Administration" }));
await user.click(screen.getByRole("button", { name: "Pi management" }));
await user.click(screen.getByRole("button", { name: "Pi configuration" }));
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
const dialog = screen.getByRole("main", { name: "Pi management" });
const workArea = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
const piManagement = within(sessionNavigation).getByRole("button", { name: "Pi management" });
const piManagement = within(sessionNavigation).getByRole("button", { name: "Pi configuration" });
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
expect(workArea).toContainElement(dialog);
expect(sessionNavigation).not.toContainElement(dialog);
@@ -151,7 +151,7 @@ test("Pi management preserves the open session summary and the model activity ti
await user.click(screen.getByRole("button", { name: "Resume" }));
await screen.findByRole("button", { name: "Show model activity" });
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Preserved activity" }));
await user.click(screen.getByRole("button", { name: "Pi management" }));
await user.click(screen.getByRole("button", { name: "Pi configuration" }));
await user.click(await screen.findByRole("button", { name: "Return to session" }));
await waitFor(() => expect(screen.queryByRole("heading", { name: "Pi management" })).not.toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "Show model activity" }));
+5 -5
View File
@@ -999,7 +999,7 @@ export function AppShell({ canLogout }: AppShellProps) {
navigate({ surface: "database-management" }, true);
}}
>
Database management
Database
</Button>
<Button
variant={currentNavigation === "memory" ? "navigationActive" : "outline"}
@@ -1013,13 +1013,13 @@ export function AppShell({ canLogout }: AppShellProps) {
if (!canLeaveDatabaseManagement()) return;
navigate({ surface: "memory-management" }, true);
}}
>Memory management</Button>
>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 : "Evidence management permission is required"}
onClick={() => navigate({ surface: "evidence-management" })}
>Evidence management</Button>
>Evidence</Button>
<div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" />
<Button
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
@@ -1032,7 +1032,7 @@ export function AppShell({ canLogout }: AppShellProps) {
navigate({ surface: "workspace-management" }, true);
}}
>
Workspace management
Workspace
</Button>
<Button
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
@@ -1047,7 +1047,7 @@ export function AppShell({ canLogout }: AppShellProps) {
navigate({ surface: "pi-management" }, true);
}}
>
Pi management
Pi configuration
</Button>
<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"}
@@ -90,12 +90,14 @@ function renderPage({
canManageSecrets = true,
onNavigationStateChange,
presentation = "legacy",
initialWorkspaceId,
}: {
rows?: CatalogDatabase[] | (() => CatalogDatabase[]);
canManage?: boolean;
canManageSecrets?: boolean;
onNavigationStateChange?: (state: { dirty: boolean; busy: boolean }) => void;
presentation?: "legacy" | "fleet";
initialWorkspaceId?: string;
} = {}) {
server.use(http.get("/api/catalog/databases", () => HttpResponse.json(
typeof rows === "function" ? rows() : rows,
@@ -104,6 +106,7 @@ function renderPage({
const view = render(
<QueryClientProvider client={client}>
<DatabaseManagementPage
initialWorkspaceId={initialWorkspaceId}
canManage={canManage}
canManageSecrets={canManageSecrets}
onNavigationStateChange={onNavigationStateChange}
@@ -115,6 +118,15 @@ function renderPage({
return { ...view, client };
}
test("fleet entry opens the database list without workspace preparation or an automatic editor", async () => {
renderPage({ presentation: "fleet", initialWorkspaceId: "psd-clinical" });
expect(await screen.findByRole("button", { name: "View tables for Policlinico San Donato" })).toBeVisible();
expect(screen.getByText("Research laboratory")).toBeVisible();
expect(screen.queryByRole("button", { name: "Back to list" })).not.toBeInTheDocument();
expect(screen.queryByText(/Workspace preparation/)).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Open workspace preprocessing" })).not.toBeInTheDocument();
});
function makeSyncRun(
scope: CatalogSyncRun["scope"],
tableIds: string[] = [],
@@ -10,7 +10,6 @@ import { CheckCircle2, CircleAlert, History, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { workspacePreferences } from "../workspaces/preferences";
import { useInteractionModelBusy } from "../models/useInteractionModelBusy";
import { WorkspacePreparation } from "./administration/WorkspacePreparation";
import { Button } from "../components/ui/button";
import {
Dialog,
@@ -161,7 +160,6 @@ function isDescriptionGenerationActive(run?: DescriptionGenerationRun | null): b
}
export function DatabaseManagementPage({
initialWorkspaceId,
canManage,
canManageSecrets,
onNavigationStateChange,
@@ -411,15 +409,6 @@ export function DatabaseManagementPage({
});
}, []);
const initialWorkspaceOpened = useRef(false);
useEffect(() => {
if (!initialWorkspaceId || initialWorkspaceOpened.current || !data) return;
initialWorkspaceOpened.current = true;
const row = data.find(item => item.workspaceId === initialWorkspaceId);
if (row) openForm(row.configured ? "edit" : "configure", row, document.body);
else toast.info("This workspace's database configuration is unavailable");
}, [initialWorkspaceId, data, openForm]);
const viewRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
openForm("view", row, origin);
}, [openForm]);
@@ -1224,7 +1213,6 @@ export function DatabaseManagementPage({
onOpenSensitiveHistory={openSensitivityReviewItemHistory}
activeSyncRun={currentActiveRun}
/>
<WorkspacePreparation workspaceId={activeRow.workspaceId} canManage={canManage} databaseSummary />
</div>
</div>
) : null;
@@ -1403,8 +1391,6 @@ export function DatabaseManagementPage({
)}
children={<>
{fleetDrawer ?? <FleetLedgerContent busy={isLoading || isFetching}>{fleetContent}</FleetLedgerContent>}
{!fleetDrawer && (activeRow ?? rows.find(row => row.id === selectedDatabaseId)) &&
<WorkspacePreparation workspaceId={(activeRow ?? rows.find(row => row.id === selectedDatabaseId))!.workspaceId} canManage={canManage} databaseSummary />}
</>}
drawer={(
<>
+16 -2
View File
@@ -13,11 +13,11 @@ const readyStatus = {
checkedAt: "2026-08-05T10:00:00.000Z",
};
function renderManagement() {
function renderManagement(page = false) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<PiManagement open onClose={() => undefined} />
<PiManagement open page={page} onClose={() => undefined} />
</QueryClientProvider>,
);
}
@@ -52,6 +52,20 @@ test("shows the catalog default as read-only installation state", async () => {
expect(document.querySelector('input[type="password"]')).toBeNull();
});
test.each(["linux", "macos", "windows"])("opens host instructions for the installation platform %s", async hostPlatform => {
server.use(http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, hostPlatform })));
renderManagement(true);
await screen.findByText("Pi 0.80.3");
await userEvent.click(screen.getByRole("button", { name: "Host maintenance" }));
const label = hostPlatform === "macos" ? "macOS" : hostPlatform === "windows" ? "Windows" : "Linux";
expect(screen.getByRole("tab", { name: label })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel", { name: label })).toBeVisible();
await userEvent.click(screen.getByRole("tab", { name: "Windows" }));
await userEvent.click(screen.getByRole("button", { name: "Runtime and models" }));
await userEvent.click(screen.getByRole("button", { name: "Host maintenance" }));
expect(screen.getByRole("tabpanel", { name: "Windows" })).toBeVisible();
});
test("shows every session model exposed by Core", async () => {
server.use(http.get("/api/models", () => HttpResponse.json({ models: [
{ provider: "zai", id: "glm-5.3", name: "GLM 5.3", reasoning: true },
+8 -9
View File
@@ -202,13 +202,12 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
</ol>;
}
function PiPlatformInstructions() {
const [activePlatform, setActivePlatform] = useState<PiPlatform>();
const [focusedPlatform, setFocusedPlatform] = useState<PiPlatform>("linux");
function PiPlatformInstructions({ hostPlatform }: { hostPlatform?: PiPlatform }) {
const [chosenPlatform, setActivePlatform] = useState<PiPlatform>();
const activePlatform = chosenPlatform ?? hostPlatform ?? "linux";
const tabRefs = useRef<Record<PiPlatform, HTMLButtonElement | null>>({ linux: null, macos: null, windows: null });
function activateAndFocus(platform: PiPlatform) {
setFocusedPlatform(platform);
setActivePlatform(platform);
tabRefs.current[platform]?.focus();
}
@@ -242,13 +241,11 @@ function PiPlatformInstructions() {
role="tab"
aria-selected={activePlatform === platform.id}
aria-controls={`pi-${platform.id}-panel`}
tabIndex={focusedPlatform === platform.id ? 0 : -1}
tabIndex={activePlatform === platform.id ? 0 : -1}
className={activePlatform === platform.id ? "rounded bg-background px-2 py-1.5 font-semibold text-foreground shadow-sm" : "rounded px-2 py-1.5 font-semibold text-muted-foreground hover:bg-background/70 hover:text-foreground"}
onFocus={() => setFocusedPlatform(platform.id)}
onKeyDown={(event) => handleTabKeyDown(event, platform.id)}
onClick={() => {
setFocusedPlatform(platform.id);
setActivePlatform((current) => current === platform.id ? undefined : platform.id);
setActivePlatform(platform.id);
}}
>
{platform.label}
@@ -348,7 +345,9 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
<Button variant={section === "instructions" ? "navigationActive" : "ghost"} className="w-full justify-start whitespace-normal" aria-current={section === "instructions" ? "page" : undefined} onClick={() => setSection("instructions")}>Host maintenance</Button>
</nav>}
<div className={page ? "thot-pi-detail" : undefined}>
{(!page || section === "instructions") && <PiPlatformInstructions />}
<div hidden={page && section !== "instructions"}>
<PiPlatformInstructions hostPlatform={statusQuery.data?.hostPlatform} />
</div>
<div className={page ? undefined : "mt-5"} hidden={page && section !== "runtime"}>
{forbidden ? (
<div role="alert" aria-label="Pi management unavailable" className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
+24
View File
@@ -10,6 +10,30 @@
border-inline-end: 1px solid oklch(var(--border));
}
.thot-workspace-area-tabs {
display: flex;
gap: 0.25rem;
overflow-x: auto;
border-bottom: 1px solid oklch(var(--border));
}
.thot-workspace-area-tabs [role="tab"] {
flex: none;
padding: 0.75rem 1rem;
border-bottom: 2px solid transparent;
color: oklch(var(--muted-foreground));
font-size: 0.875rem;
font-weight: 600;
}
.thot-workspace-area-tabs [aria-selected="true"] {
border-bottom-color: oklch(var(--primary));
color: oklch(var(--primary));
background: oklch(var(--muted) / 0.4);
}
.thot-workspace-area-tabs [role="tab"]:focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: -3px;
}
@container work-area-panel (max-width: 45rem) {
.thot-workspace-manager__layout {
grid-template-columns: minmax(0, 1fr);
@@ -136,6 +136,28 @@ test("defaults workspace mutations and secrets to denied", async () => {
expect(screen.queryByRole("heading", { name: "Runtime secrets" })).not.toBeInTheDocument();
});
test("page tabs group workspace areas and preserve unsaved secrets across tabs", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}>
<WorkspaceManager open page initialWorkspaceId="psd-clinical" onClose={vi.fn()} canManageWorkspace canManageSecrets />
</QueryClientProvider>);
const tabs = await screen.findByRole("tablist", { name: "Workspace areas" });
expect(within(tabs).getAllByRole("tab").map(tab => tab.textContent)).toEqual([
"Preparation", "Specific actions", "About", "Authentication",
]);
expect(screen.getByRole("tab", { name: "Preparation" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel", { name: "Preparation" })).toBeVisible();
await userEvent.click(screen.getByRole("tab", { name: "Specific actions" }));
expect(await screen.findByRole("button", { name: "Validate workspace source" })).toBeVisible();
await userEvent.click(screen.getByRole("tab", { name: "Authentication" }));
await userEvent.type(await screen.findByLabelText("Data warehouse password"), "unsaved-value");
await userEvent.click(screen.getByRole("tab", { name: "About" }));
expect(screen.getByRole("heading", { name: "About this workspace" })).toBeVisible();
await userEvent.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Authentication" })).toHaveFocus();
expect(screen.getByLabelText("Data warehouse password")).toHaveValue("unsaved-value");
});
test("uses the shared modeless work-area panel and keeps workspace content scrollable", () => {
renderManager();
+48 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
@@ -76,6 +76,8 @@ type WorkspaceOperationGuard = Readonly<{
import { useWorkingContextScope } from "../workspaces/WorkingContext";
const workspaceAreas = ["Preparation", "Specific actions", "About", "Authentication"] as const;
export function WorkspaceManager({
open,
onClose,
@@ -96,6 +98,18 @@ export function WorkspaceManager({
canManageSecrets?: boolean;
}) {
const workingContext = useWorkingContextScope();
const tabsId = useId();
const [activeArea, setActiveArea] = useState<typeof workspaceAreas[number]>("Preparation");
const areaTabs = useRef<Array<HTMLButtonElement | null>>([]);
const areaId = (area: typeof workspaceAreas[number]) => `${tabsId}-${workspaceAreas.indexOf(area)}`;
const areaPanel = (area: typeof workspaceAreas[number]) => ({
role: page ? "tabpanel" : undefined,
id: `${areaId(area)}-panel`,
"aria-labelledby": page ? `${areaId(area)}-tab` : undefined,
hidden: page && activeArea !== area,
tabIndex: page ? 0 : undefined,
className: "space-y-5",
});
const queryClient = useQueryClient();
const [selectedId, setSelectedId] = useState<string | undefined>(initialWorkspaceId);
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
@@ -530,7 +544,31 @@ export function WorkspaceManager({
<p className="font-mono text-xs text-muted-foreground">{selectedSummary.id}</p>
</div>
{page && selectedId && <WorkspacePreparation workspaceId={selectedId} canManage={canPreprocess} />}
{page && <div role="tablist" aria-label="Workspace areas" className="thot-workspace-area-tabs">
{workspaceAreas.map((area, index) => <button
key={area}
ref={element => { areaTabs.current[index] = element; }}
type="button"
role="tab"
id={`${areaId(area)}-tab`}
aria-controls={`${areaId(area)}-panel`}
aria-selected={activeArea === area}
tabIndex={activeArea === area ? 0 : -1}
onClick={() => setActiveArea(area)}
onKeyDown={event => {
const next = event.key === "ArrowRight" ? (index + 1) % workspaceAreas.length
: event.key === "ArrowLeft" ? (index + workspaceAreas.length - 1) % workspaceAreas.length
: event.key === "Home" ? 0 : event.key === "End" ? workspaceAreas.length - 1 : undefined;
if (next === undefined) return;
event.preventDefault();
setActiveArea(workspaceAreas[next]);
areaTabs.current[next]?.focus();
}}
>{area}</button>)}
</div>}
{page && selectedId && <div {...areaPanel("Preparation")}>
<WorkspacePreparation workspaceId={selectedId} canManage={canPreprocess} />
</div>}
{(detailQuery.isLoading || runtimeQuery.isLoading) && <p className="text-sm text-muted-foreground">Loading workspace configuration…</p>}
{(detailQuery.isError || runtimeQuery.isError) && (
@@ -539,6 +577,7 @@ export function WorkspaceManager({
{detailQuery.data && runtime && (
<>
<div {...areaPanel("About")}>
<div className="max-w-3xl">
<h3 className="font-heading text-lg font-semibold">About this workspace</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
@@ -555,7 +594,9 @@ export function WorkspaceManager({
<div><dt className="text-xs text-muted-foreground">Workspace language</dt><dd className="font-mono text-xs">{detailQuery.data.workspace.workspace.language}</dd></div>
<div><dt className="text-xs text-muted-foreground">Runtime status</dt><dd>{stateLabel(runtime.configurationState)}</dd></div>
</dl>
</div>
<div {...areaPanel("Specific actions")}>
<div>
<h3 className="font-heading text-lg font-semibold">Workspace-specific actions</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">The validation and connection checks below apply only to <span className="font-medium text-foreground">{selectedSummary.displayName}</span> and read its active revision without changing or publishing it.</p>
@@ -606,6 +647,10 @@ export function WorkspaceManager({
</div>
</div>
</div>
<div {...areaPanel("Authentication")}>
{!authentication && <p className="text-sm text-muted-foreground">Run a connection test in Specific actions to inspect authentication diagnostics.</p>}
{authentication && <section data-testid="workspace-authentication" className="rounded-lg border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="font-heading font-semibold">Authentication</h4>
@@ -693,6 +738,7 @@ export function WorkspaceManager({
Runtime secret controls are available only to authorized workspace operators.
</p>
)}
</div>
</>
)}
</section>
@@ -82,36 +82,41 @@
.thot-administration-dependency p { max-width: 72ch; margin-bottom: 0.5rem; }
.thot-administration-mobile-nav { position: absolute; inset: 0 0 auto; z-index: 30; display: flex; height: 3rem; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0 1rem; border-bottom: 1px solid oklch(var(--border)); background: oklch(var(--background)); }
.thot-app-shell[data-administration-narrow="true"] { padding-top: 3rem; }
.thot-work-area-page .thot-workspace-manager__layout { flex: 1; min-height: 22rem; }
.thot-work-area-page .thot-work-area-panel__body--flush { display: flex; overflow: hidden; }
.thot-work-area-page .thot-workspace-manager__layout { flex: 1; min-height: 0; }
.thot-work-area-page .thot-workspace-manager__content { padding: 1.4rem; }
.thot-work-area-page__body--pi { padding: 1.5rem 1.75rem; }
.thot-pi-workbench { display: grid; grid-template-columns: minmax(12rem, 0.55fr) minmax(0, 1.5fr); border: 1px solid oklch(var(--border)); background: oklch(var(--card)); }
.thot-work-area-page__body--pi { display: flex; padding: 1.5rem 1.75rem; overflow: hidden; }
.thot-pi-workbench { display: grid; flex: 1; min-width: 0; min-height: 0; grid-template-columns: minmax(12rem, 0.55fr) minmax(0, 1.5fr); border: 1px solid oklch(var(--border)); background: oklch(var(--card)); }
.thot-pi-index { background: oklch(var(--muted) / 0.45); border-inline-end: 1px solid oklch(var(--border)); padding: 1.25rem; }
.thot-pi-index a { display: block; padding: 0.65rem 0; font-size: 0.875rem; }
.thot-pi-detail { min-width: 0; padding: 1.35rem; }
.thot-pi-index { min-height: 0; overflow-y: auto; }
.thot-pi-detail { min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 1.35rem; }
.thot-pi-detail :where(section, ol, li, div, pre, dl, dd) { min-width: 0; max-width: 100%; }
.thot-pi-detail :where(p, dt, dd) { overflow-wrap: anywhere; }
.thot-pi-detail [data-testid="pi-platform-instructions-scroll"] { max-height: none; overflow: visible; }
.thot-workspace-preparation { border-block-start: 1px solid oklch(var(--border)); padding-top: 1.25rem; }
.thot-workspace-preparation .thot-preprocessing-control { max-width: 40rem; }
.thot-preprocessing-control[data-presentation="page"] { padding: 1rem; margin-top: 0.75rem; }
.thot-preprocessing-control[data-presentation="page"] :where(strong, span, small, button) { font-size: 0.8125rem; line-height: 1.5; }
.thot-preprocessing-control[data-presentation="page"] button { height: auto; min-height: 2.25rem; white-space: normal; }
.thot-administration-database .thot-fleet-ledger__workspace { overflow-y: auto; }
.thot-administration-database .thot-fleet-ledger__content { flex: 1 0 26rem; min-height: 26rem; }
.thot-administration-database .thot-fleet-ledger__kpi-value { font-size: 1.125rem; }
.thot-administration-database .thot-administration-dependency { flex: none; }
.thot-database-workbench { grid-template-columns: minmax(10rem, 0.55fr) minmax(0, 1.5fr); }
.thot-database-workbench form > .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@container administration (max-width: 48rem) {
.thot-administration-split, .thot-pi-workbench { grid-template-columns: minmax(0, 1fr); }
.thot-pi-workbench { grid-template-rows: auto minmax(0, 1fr); }
.thot-database-workbench form > .grid { grid-template-columns: minmax(0, 1fr); }
.thot-administration-index, .thot-pi-index { border-inline-end: 0; border-block-end: 1px solid oklch(var(--border)); }
.thot-administration-index { max-height: 22rem; overflow-y: auto; }
.thot-administration-header { padding: 1.25rem; }
.thot-administration-split { margin: 1rem; }
.thot-work-area-page .thot-workspace-manager__layout { display: flex; flex-direction: column; overflow: visible; }
.thot-work-area-page .thot-workspace-manager__navigation { max-height: 18rem; flex: none; border-inline-end: 0; border-block-end: 1px solid oklch(var(--border)); }
.thot-work-area-page .thot-workspace-manager__content { overflow: visible; }
.thot-work-area-page .thot-workspace-manager__layout { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, auto) minmax(0, 1fr); }
.thot-work-area-page .thot-workspace-manager__navigation { max-height: 10rem; border-inline-end: 0; border-block-end: 1px solid oklch(var(--border)); }
}
@container administration (max-width: 30rem) {
.thot-pi-detail dl { display: block; }
.thot-pi-detail dt { margin-top: 0.75rem; }
.thot-administration-header h1 { font-size: 1.5rem; }
.thot-administration-header, .thot-administration-toolbar, .thot-work-area-page__body--pi { padding: 1rem; }
.thot-administration-detail, .thot-pi-detail { padding: 1rem; }
@@ -3,18 +3,12 @@ import { getWorkspacePreprocessingStatus } from "../../api/workspace-preprocessi
import { WorkspacePreprocessingControl } from "../WorkspacePreprocessingControl";
import { AdministrationLink } from "./AdministrationNavigation";
export function WorkspacePreparation({ workspaceId, canManage, databaseSummary = false }: {
workspaceId: string; canManage: boolean; databaseSummary?: boolean;
export function WorkspacePreparation({ workspaceId, canManage }: {
workspaceId: string; canManage: boolean;
}) {
const query = useQuery({ queryKey: ["workspace-preprocessing", workspaceId],
queryFn: () => getWorkspacePreprocessingStatus(workspaceId), enabled: canManage, retry: false, refetchInterval: 5000 });
if (!canManage) return <p className="text-sm text-muted-foreground">Database management permission is required to inspect or run preprocessing.</p>;
if (databaseSummary) return <section aria-label="Workspace preparation dependency" className="thot-administration-dependency">
<p><strong>Workspace preparation</strong> · {workspaceId}</p>
<p role="status">{query.isLoading ? "Checking workspace readiness…" : query.isError ? "Workspace readiness is unavailable." : query.data?.detail}</p>
{query.isError && <button className="thot-administration-link" onClick={() => void query.refetch()}>Retry readiness</button>}
<AdministrationLink surface="workspace-management" workspace={workspaceId} className="thot-administration-link">Open workspace preprocessing</AdministrationLink>
</section>;
return <section aria-label="Workspace readiness" className="thot-workspace-preparation">
<p className="thot-administration-eyebrow">Preparation</p>
<h3 className="font-heading text-lg font-semibold mt-1">Ready for analysis</h3>