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
+3
View File
@@ -30,6 +30,8 @@ export interface AppConfig {
dataRoot?: string; dataRoot?: string;
ollamaEnsureTimeoutMs: number; ollamaEnsureTimeoutMs: number;
piManagementTimeoutMs: number; piManagementTimeoutMs: number;
/** Host CLI platform projected into Docker; not the browser or container OS. */
hostPlatform?: string;
secretsFile?: string; secretsFile?: string;
installationConfigFile?: string; installationConfigFile?: string;
evidenceHostRegistryRoot?: string; evidenceHostRegistryRoot?: string;
@@ -475,6 +477,7 @@ export function loadConfig(
dataRoot: env.THT_DATA_ROOT, dataRoot: env.THT_DATA_ROOT,
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000), ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
piManagementTimeoutMs: piManagementTimeout(env.PI_MANAGEMENT_TIMEOUT_MS), piManagementTimeoutMs: piManagementTimeout(env.PI_MANAGEMENT_TIMEOUT_MS),
hostPlatform: env.THT_HOST_PLATFORM,
secretsFile, secretsFile,
installationConfigFile, installationConfigFile,
evidenceHostRegistryRoot: env.THT_EVIDENCE_HOST_REGISTRY_ROOT || undefined, evidenceHostRegistryRoot: env.THT_EVIDENCE_HOST_REGISTRY_ROOT || undefined,
+6 -2
View File
@@ -36,6 +36,7 @@ export interface PiInstallationConfig {
} }
export interface PiStatus { export interface PiStatus {
hostPlatform: "linux" | "macos" | "windows";
version?: string; version?: string;
ready: boolean; ready: boolean;
credentials: PiCredentialStatus; credentials: PiCredentialStatus;
@@ -92,6 +93,9 @@ interface PiManagementDeps {
} }
export function createPiManagement(config: AppConfig, deps: PiManagementDeps): PiManagementService { export function createPiManagement(config: AppConfig, deps: PiManagementDeps): PiManagementService {
const platform = config.hostPlatform ?? process.platform;
const hostPlatform = platform === "darwin" ? "macos"
: platform === "windows" || platform === "win32" ? "windows" : "linux";
const now = deps.now ?? (() => new Date()); const now = deps.now ?? (() => new Date());
const diagnostics: string[] = []; const diagnostics: string[] = [];
const addDiagnostic = (message: string): void => { const addDiagnostic = (message: string): void => {
@@ -169,11 +173,11 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
try { try {
const currentVersion = await version(); const currentVersion = await version();
addDiagnostic("Pi version probe succeeded"); addDiagnostic("Pi version probe succeeded");
return { version: currentVersion, ready: true, credentials, config: current, checkedAt }; return { hostPlatform, version: currentVersion, ready: true, credentials, config: current, checkedAt };
} catch (error) { } catch (error) {
const message = stableMessage(error, "Pi runtime is unavailable"); const message = stableMessage(error, "Pi runtime is unavailable");
addDiagnostic(message); addDiagnostic(message);
return { ready: false, credentials, config: current, checkedAt, message }; return { hostPlatform, ready: false, credentials, config: current, checkedAt, message };
} }
}, },
+13
View File
@@ -42,6 +42,7 @@ function configFor(settingsFile = join(mkdtempSync(join(tmpdir(), "tht-pi-manage
SETTINGS_FILE: settingsFile, SETTINGS_FILE: settingsFile,
PI_BIN: "/usr/local/bin/pi", PI_BIN: "/usr/local/bin/pi",
PI_MANAGEMENT_TIMEOUT_MS: "750", PI_MANAGEMENT_TIMEOUT_MS: "750",
THT_HOST_PLATFORM: "linux",
}); });
} }
@@ -60,6 +61,16 @@ function successfulExec(calls: Array<{ command: string; args: string[]; timeout:
}; };
} }
test.each([
["linux", "linux"], ["darwin", "macos"], ["windows", "windows"],
])("reports installation host %s independently of the backend container OS", async (host, expected) => {
const service = createPiManagement(loadConfig({ THT_HOST_PLATFORM: host }), {
modelCatalog, execute: successfulExec([]), readSettings: () => ({ thinking: "medium" }),
credentialStatus: () => "missing",
});
expect((await service.status()).hostPlatform).toBe(expected);
});
// Catches a Pi executable that emits unexpected text or is invoked through a shell, which could // Catches a Pi executable that emits unexpected text or is invoked through a shell, which could
// turn a version display into a command-injection or information-disclosure surface. // turn a version display into a command-injection or information-disclosure surface.
test("status parses only a Pi version from a fixed execFile argument array", async () => { test("status parses only a Pi version from a fixed execFile argument array", async () => {
@@ -73,6 +84,7 @@ test("status parses only a Pi version from a fixed execFile argument array", asy
}); });
await expect(service.status()).resolves.toEqual({ await expect(service.status()).resolves.toEqual({
hostPlatform: "linux",
version: "0.80.3", version: "0.80.3",
ready: true, ready: true,
credentials: "missing", credentials: "missing",
@@ -104,6 +116,7 @@ test.each(["present", "missing"] as const)(
const status = await service.status(); const status = await service.status();
expect(status).toEqual({ expect(status).toEqual({
hostPlatform: "linux",
version: "0.80.3", version: "0.80.3",
ready: true, ready: true,
credentials, credentials,
+13
View File
@@ -8,6 +8,19 @@ declare providers, model allowlists, defaults, embeddings, dimensions, or vector
Do not edit `deploy/pi/models.json`, `deploy/pi/settings.json`, files under `generated/`, or Do not edit `deploy/pi/models.json`, `deploy/pi/settings.json`, files under `generated/`, or
provider/model environment defaults. Those former sources are retired. provider/model environment defaults. Those former sources are retired.
## Host instructions in Administration
The **Pi configuration** navigation button opens **Pi management**. Its Host maintenance
section selects the installation host's Linux, macOS, or Windows tab automatically; the
browser's operating system does not affect it. Selecting another tab is still possible.
The host CLI includes `THT_HOST_PLATFORM` in the generated Compose projection using the OS
on which it runs. Generate projections on the destination host, not on another computer,
and do not edit generated files. Without this projection (older deployments or native
development), the backend reports its own OS; a Linux Docker container cannot discover
whether the physical host is macOS or Windows. Run the updated host CLI's normal
configuration reload on the destination installation to regenerate this information.
## Minimal catalog ## Minimal catalog
```yaml ```yaml
@@ -0,0 +1,42 @@
# Administration follow-up: Gitea #28–#31
Baseline approved by the user: `f52bf22e` on `codex/prototype-administration-pages`.
Existing Admin, Core, sessions, and unified-model work is outside this review diff.
## Ticket acceptance
- [#28](https://git.tylconsulting.it/mptyl/ThothII/issues/28): Workspace page has four
tabs, in order: Preparation, Specific actions, About, Authentication. Every area's
content remains vertically accessible, with scrolling where required.
- [#29](https://git.tylconsulting.it/mptyl/ThothII/issues/29): Database opens at the
existing database list instead of automatically editing a connection. Owner comment:
remove the “Workspace preparation” footer and restore the table's original available
space and margins. Tables and columns remain accessible through the original grid actions.
- [#30](https://git.tylconsulting.it/mptyl/ThothII/issues/30): Pi fills the available
height minus its margins, scrolls overflowing content, and initially opens the tab
for the installation OS (Linux on a Linux server, macOS or Windows otherwise).
- [#31](https://git.tylconsulting.it/mptyl/ThothII/issues/31): Keep “management” in page
titles, remove it from Admin buttons; use “Pi configuration” for Pi.
## Scope and compatibility
The global workspace/model gate, operation locks, original Core, session navigation,
and unsaved-change guards are retained. The list currently retains the existing global
workspace scope; an asynchronous question asks whether the user wants to broaden this
to all workspaces. No cross-workspace operations are introduced without that decision.
Workspace tab panels stay mounted, preserving secret drafts and diagnostics. Pi platform
selection stays mounted across the two Pi sections and does not replace a manual choice
when status refreshes. OS information is projected by the host CLI, not inferred from
the browser. Older unprojected installations fall back to the backend runtime OS; see
the [operator guide](../general/pi-configuration.md#host-instructions-in-administration).
## Verification
TDD seams: AppShell navigation and public management-page behavior with MSW API fixtures;
the existing public Pi service and host projection contracts cover OS transport.
Red tests were observed before adding tabs, restoring list entry, selecting OS tabs,
projecting the host platform, and shortening navigation labels.
Verification results and independent Standards/Spec reviews are recorded below when complete.
Docker has not been rebuilt for this follow-up; the read-only QA preview uses the changed source.
@@ -439,12 +439,12 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
await administration.click(); await administration.click();
await expect(administration).toHaveAttribute("aria-expanded", "false"); 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 administration.click();
await expect(administration).toHaveAttribute("aria-expanded", "true"); await expect(administration).toHaveAttribute("aria-expanded", "true");
await adminNavigationRail await adminNavigationRail
.getByRole("button", { name: "Database management", exact: true }) .getByRole("button", { name: "Database", exact: true })
.click(); .click();
await expectCurrentNavigation(page, "Database management"); 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 expectWorkAreaPanelGeometry(page, "Workspace management");
await expectWorkAreaPanelHeaderUsesPrimary(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 piTrigger.click();
await expectCurrentNavigation(page, "Pi management"); await expectCurrentNavigation(page, "Pi management");
await expect( 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.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"); await expectCurrentNavigation(page, "Workspace management");
const compactWorkArea = page.getByTestId("conversation-column"); const compactWorkArea = page.getByTestId("conversation-column");
const compactPanel = compactWorkArea.getByRole("dialog", { name: "Workspace management" }); 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([ }).allTextContents()).map(text => text.trim())).toEqual([
"Database management", "Memory management", "Evidence management", "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 }); await page.screenshot({ path: "/private/tmp/thothii-m1-memory-opening.png", fullPage: true });
const memory = page.getByRole("main", { name: "Memory management" }); const memory = page.getByRole("main", { name: "Memory management" });
await expect(memory).toBeVisible(); await expect(memory).toBeVisible();
@@ -157,9 +157,9 @@ finally: service.close()
THT_PRINCIPAL_ISSUER: "test", THT_PRINCIPAL_SUBJECT: "admin", THT_PRINCIPAL_IS_ADMIN: "true" }); THT_PRINCIPAL_ISSUER: "test", THT_PRINCIPAL_SUBJECT: "admin", THT_PRINCIPAL_IS_ADMIN: "true" });
expect(JSON.parse(recalled)).toEqual([]); expect(JSON.parse(recalled)).toEqual([]);
await expect(page.getByRole("button", { name: "New card", exact: true })).toBeEnabled(); 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: "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" }); const evidence = page.getByRole("main", { name: "Evidence management" });
await expect(evidence).toBeVisible(); await expect(evidence).toBeVisible();
await evidence.getByRole("combobox", { name: /Workspace/ }).selectOption("fixture-workspace"); 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.screenshot({ path: "/private/tmp/thothii-acceptance-evidence-mobile.png", fullPage: true });
await page.getByRole("button", { name: "Navigation", exact: true }).click(); await page.getByRole("button", { name: "Navigation", exact: true }).click();
const navigation = page.getByRole("dialog", { name: "Navigation", exact: true }); 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 page.keyboard.press("Escape");
await expect(navigation).toBeHidden(); await expect(navigation).toBeHidden();
await expect(page.getByRole("button", { name: "Navigation", exact: true })).toBeFocused(); await expect(page.getByRole("button", { name: "Navigation", exact: true })).toBeFocused();
await page.getByRole("button", { name: "Navigation", exact: true }).click(); 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(); await expect(navigation).toBeHidden();
const mobileMemory = page.getByRole("main", { name: "Memory management" }); const mobileMemory = page.getByRole("main", { name: "Memory management" });
await expect(mobileMemory).toBeVisible(); await expect(mobileMemory).toBeVisible();
+1
View File
@@ -9,6 +9,7 @@ export interface PiInstallationConfig {
} }
export interface PiManagementStatus { export interface PiManagementStatus {
hostPlatform?: "linux" | "macos" | "windows";
version?: string; version?: string;
ready: boolean; ready: boolean;
credentials: "present" | "missing"; credentials: "present" | "missing";
@@ -39,6 +39,17 @@ beforeEach(() => {
}); });
afterEach(() => vi.restoreAllMocks()); 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 => { 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`); window.history.replaceState({ portal: "kept" }, "", `/portal?lang=it&thoth_route=administration/${name.toLowerCase()}#host`);
renderShell(); 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"); window.history.replaceState({ portal: { view: "thoth" } }, "", "/portal?lang=it#host");
renderShell(); renderShell();
await userEvent.click(screen.getByRole("button", { name: "Administration" })); 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" }));
await userEvent.click(screen.getByRole("button", { name: "Pi management" })); await userEvent.click(screen.getByRole("button", { name: "Pi configuration" }));
act(() => window.history.back()); act(() => window.history.back());
expect(await screen.findByRole("main", { name: "Workspace management" })).toBeVisible(); expect(await screen.findByRole("main", { name: "Workspace management" })).toBeVisible();
act(() => window.history.forward()); 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 () => { test("Back and Core navigation preserve a Memory draft until the editor cancels it", async () => {
renderShell(); renderShell();
await userEvent.click(screen.getByRole("button", { name: "Administration" })); 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" }));
await userEvent.click(screen.getByRole("button", { name: "Memory management" })); await userEvent.click(screen.getByRole("button", { name: "Memory" }));
const page = await screen.findByRole("main", { name: "Memory management" }); const page = await screen.findByRole("main", { name: "Memory management" });
await userEvent.click(await within(page).findByRole("button", { name: "New card" })); await userEvent.click(await within(page).findByRole("button", { name: "New card" }));
await userEvent.type(within(page).getByLabelText("Title"), "Keep this draft"); 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" })); await userEvent.click(screen.getByRole("button", { name: "Done" }));
expect(screen.queryByRole("combobox", { name: "Model" })).not.toBeInTheDocument(); expect(screen.queryByRole("combobox", { name: "Model" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Administration" })); 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(await screen.findByRole("main", { name: "Memory management" })).toBeVisible();
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument(); expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Return to session" })); 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"], renderShell({ subject: "admin", isAdmin: true, roles: ["admin"],
permissions: ["session.use", "memory.manage", "database.manage"] }); permissions: ["session.use", "memory.manage", "database.manage"] });
await userEvent.click(await screen.findByRole("button", { name: "Administration" })); 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" }); const memory = screen.getByRole("main", { name: "Memory management" });
expect(memory).toBeVisible(); expect(memory).toBeVisible();
expect(within(memory).queryByRole("combobox", { name: "Workspace" })).not.toBeInTheDocument(); 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(await screen.findByRole("button", { name: "New session" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Database management" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Database" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Workspace" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Pi configuration" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "All sessions" })).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"); expect(trigger).toHaveAttribute("aria-expanded", "true");
const panel = within(sessionNavigation).getByRole("region", { name: "Administration" }); const panel = within(sessionNavigation).getByRole("region", { name: "Administration" });
const database = within(panel).getByRole("button", { name: "Database management" }); const database = within(panel).getByRole("button", { name: "Database" });
const memory = within(panel).getByRole("button", { name: "Memory management" }); const memory = within(panel).getByRole("button", { name: "Memory" });
const evidence = within(panel).getByRole("button", { name: "Evidence management" }); const evidence = within(panel).getByRole("button", { name: "Evidence" });
const separator = within(panel).getByRole("separator"); const separator = within(panel).getByRole("separator");
const workspace = within(panel).getByRole("button", { name: "Workspace management" }); const workspace = within(panel).getByRole("button", { name: "Workspace" });
const pi = within(panel).getByRole("button", { name: "Pi management" }); const pi = within(panel).getByRole("button", { name: "Pi configuration" });
expect(panel.children[0]).toBe(database); expect(panel.children[0]).toBe(database);
expect(panel.children[1]).toBe(memory); expect(panel.children[1]).toBe(memory);
expect(panel.children[2]).toBe(evidence); expect(panel.children[2]).toBe(evidence);
@@ -156,7 +156,7 @@ describe("authenticated shell permissions", () => {
await user.keyboard(" "); await user.keyboard(" ");
expect(trigger).toHaveAttribute("aria-expanded", "false"); expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(within(sessionNavigation).queryByRole("region", { name: "Administration" })).not.toBeInTheDocument(); 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 () => { test("keeps identity but hides logout outside local authentication", async () => {
@@ -277,8 +277,8 @@ describe("authenticated shell permissions", () => {
act(() => clearAuthState()); act(() => clearAuthState());
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Workspace" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Pi configuration" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "All sessions" })).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" }); const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
expect(within(sessionNavigation).getByRole("button", { name: "New session" })) expect(within(sessionNavigation).getByRole("button", { name: "New session" }))
.toHaveAttribute("aria-current", "page"); .toHaveAttribute("aria-current", "page");
expect(within(sessionNavigation).getByRole("button", { name: "Database management" })) expect(within(sessionNavigation).getByRole("button", { name: "Database" }))
.not.toHaveAttribute("aria-current"); .not.toHaveAttribute("aria-current");
expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument(); 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 conversationColumn = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" }); const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" }); 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("aria-current", "page");
expect(newSession).toHaveAttribute("data-navigation-state", "current"); 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)); await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
const source = FakeEventSource.instances[0]; 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(screen.getByRole("main", { name: "Database management" })).toBeVisible();
expect(source.closed).toBe(false); 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.getByRole("button", { name: "New session" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Workspace" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Database management" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Database" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Pi configuration" })).not.toBeInTheDocument();
}); });
test("requires saving or cancelling a dirty database form before leaving", async () => { 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(); renderShell();
await expandAdministration(); 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"); const schema = await screen.findByLabelText("Schema");
await userEvent.clear(schema); await userEvent.clear(schema);
await userEvent.type(schema, "reporting"); await userEvent.type(schema, "reporting");
@@ -204,13 +204,13 @@ test("opens Workspace management from the right sidebar without interrupting the
renderShell(); renderShell();
await userEvent.click(screen.getByRole("button", { name: "Administration" })); 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(); expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
const dialog = screen.getByRole("main", { name: "Workspace management" }); const dialog = screen.getByRole("main", { name: "Workspace management" });
const workArea = screen.getByTestId("conversation-column"); const workArea = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" }); 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" }); const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
expect(workArea).toContainElement(dialog); expect(workArea).toContainElement(dialog);
expect(sessionNavigation).not.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")); await user.click(await screen.findByText("Attiva uno"));
expect(await screen.findByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale"); 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: "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(); expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
const dialog = screen.getByRole("main", { name: "Pi management" }); const dialog = screen.getByRole("main", { name: "Pi management" });
const workArea = screen.getByTestId("conversation-column"); const workArea = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" }); 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" }); const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
expect(workArea).toContainElement(dialog); expect(workArea).toContainElement(dialog);
expect(sessionNavigation).not.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 user.click(screen.getByRole("button", { name: "Resume" }));
await screen.findByRole("button", { name: "Show model activity" }); await screen.findByRole("button", { name: "Show model activity" });
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Preserved 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 user.click(await screen.findByRole("button", { name: "Return to session" }));
await waitFor(() => expect(screen.queryByRole("heading", { name: "Pi management" })).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByRole("heading", { name: "Pi management" })).not.toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "Show model activity" })); 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); navigate({ surface: "database-management" }, true);
}} }}
> >
Database management Database
</Button> </Button>
<Button <Button
variant={currentNavigation === "memory" ? "navigationActive" : "outline"} variant={currentNavigation === "memory" ? "navigationActive" : "outline"}
@@ -1013,13 +1013,13 @@ export function AppShell({ canLogout }: AppShellProps) {
if (!canLeaveDatabaseManagement()) return; if (!canLeaveDatabaseManagement()) return;
navigate({ surface: "memory-management" }, true); navigate({ surface: "memory-management" }, true);
}} }}
>Memory management</Button> >Memory</Button>
<Button variant={currentNavigation === "evidence" ? "navigationActive" : "outline"} <Button variant={currentNavigation === "evidence" ? "navigationActive" : "outline"}
size="sm" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]" size="sm" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "evidence" ? "page" : undefined} aria-current={currentNavigation === "evidence" ? "page" : undefined}
disabled={!canManageEvidence} title={canManageEvidence ? undefined : "Evidence management permission is required"} disabled={!canManageEvidence} title={canManageEvidence ? undefined : "Evidence management permission is required"}
onClick={() => navigate({ surface: "evidence-management" })} onClick={() => navigate({ surface: "evidence-management" })}
>Evidence management</Button> >Evidence</Button>
<div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" /> <div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" />
<Button <Button
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"} variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
@@ -1032,7 +1032,7 @@ export function AppShell({ canLogout }: AppShellProps) {
navigate({ surface: "workspace-management" }, true); navigate({ surface: "workspace-management" }, true);
}} }}
> >
Workspace management Workspace
</Button> </Button>
<Button <Button
variant={currentNavigation === "pi" ? "navigationActive" : "outline"} variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
@@ -1047,7 +1047,7 @@ export function AppShell({ canLogout }: AppShellProps) {
navigate({ surface: "pi-management" }, true); navigate({ surface: "pi-management" }, true);
}} }}
> >
Pi management Pi configuration
</Button> </Button>
<Button variant="ghost" size="sm" className="w-full min-w-0 whitespace-normal" onClick={() => navigate({ surface: "workspace-management", workspace: selectedWorkspaceId })}> <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"} Workspace readiness: {preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking"}
@@ -90,12 +90,14 @@ function renderPage({
canManageSecrets = true, canManageSecrets = true,
onNavigationStateChange, onNavigationStateChange,
presentation = "legacy", presentation = "legacy",
initialWorkspaceId,
}: { }: {
rows?: CatalogDatabase[] | (() => CatalogDatabase[]); rows?: CatalogDatabase[] | (() => CatalogDatabase[]);
canManage?: boolean; canManage?: boolean;
canManageSecrets?: boolean; canManageSecrets?: boolean;
onNavigationStateChange?: (state: { dirty: boolean; busy: boolean }) => void; onNavigationStateChange?: (state: { dirty: boolean; busy: boolean }) => void;
presentation?: "legacy" | "fleet"; presentation?: "legacy" | "fleet";
initialWorkspaceId?: string;
} = {}) { } = {}) {
server.use(http.get("/api/catalog/databases", () => HttpResponse.json( server.use(http.get("/api/catalog/databases", () => HttpResponse.json(
typeof rows === "function" ? rows() : rows, typeof rows === "function" ? rows() : rows,
@@ -104,6 +106,7 @@ function renderPage({
const view = render( const view = render(
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<DatabaseManagementPage <DatabaseManagementPage
initialWorkspaceId={initialWorkspaceId}
canManage={canManage} canManage={canManage}
canManageSecrets={canManageSecrets} canManageSecrets={canManageSecrets}
onNavigationStateChange={onNavigationStateChange} onNavigationStateChange={onNavigationStateChange}
@@ -115,6 +118,15 @@ function renderPage({
return { ...view, client }; 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( function makeSyncRun(
scope: CatalogSyncRun["scope"], scope: CatalogSyncRun["scope"],
tableIds: string[] = [], tableIds: string[] = [],
@@ -10,7 +10,6 @@ import { CheckCircle2, CircleAlert, History, RefreshCw } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { workspacePreferences } from "../workspaces/preferences"; import { workspacePreferences } from "../workspaces/preferences";
import { useInteractionModelBusy } from "../models/useInteractionModelBusy"; import { useInteractionModelBusy } from "../models/useInteractionModelBusy";
import { WorkspacePreparation } from "./administration/WorkspacePreparation";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { import {
Dialog, Dialog,
@@ -161,7 +160,6 @@ function isDescriptionGenerationActive(run?: DescriptionGenerationRun | null): b
} }
export function DatabaseManagementPage({ export function DatabaseManagementPage({
initialWorkspaceId,
canManage, canManage,
canManageSecrets, canManageSecrets,
onNavigationStateChange, 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) => { const viewRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
openForm("view", row, origin); openForm("view", row, origin);
}, [openForm]); }, [openForm]);
@@ -1224,7 +1213,6 @@ export function DatabaseManagementPage({
onOpenSensitiveHistory={openSensitivityReviewItemHistory} onOpenSensitiveHistory={openSensitivityReviewItemHistory}
activeSyncRun={currentActiveRun} activeSyncRun={currentActiveRun}
/> />
<WorkspacePreparation workspaceId={activeRow.workspaceId} canManage={canManage} databaseSummary />
</div> </div>
</div> </div>
) : null; ) : null;
@@ -1403,8 +1391,6 @@ export function DatabaseManagementPage({
)} )}
children={<> children={<>
{fleetDrawer ?? <FleetLedgerContent busy={isLoading || isFetching}>{fleetContent}</FleetLedgerContent>} {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={( drawer={(
<> <>
+16 -2
View File
@@ -13,11 +13,11 @@ const readyStatus = {
checkedAt: "2026-08-05T10:00:00.000Z", checkedAt: "2026-08-05T10:00:00.000Z",
}; };
function renderManagement() { function renderManagement(page = false) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render( return render(
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<PiManagement open onClose={() => undefined} /> <PiManagement open page={page} onClose={() => undefined} />
</QueryClientProvider>, </QueryClientProvider>,
); );
} }
@@ -52,6 +52,20 @@ test("shows the catalog default as read-only installation state", async () => {
expect(document.querySelector('input[type="password"]')).toBeNull(); 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 () => { test("shows every session model exposed by Core", async () => {
server.use(http.get("/api/models", () => HttpResponse.json({ models: [ server.use(http.get("/api/models", () => HttpResponse.json({ models: [
{ provider: "zai", id: "glm-5.3", name: "GLM 5.3", reasoning: true }, { 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>; </ol>;
} }
function PiPlatformInstructions() { function PiPlatformInstructions({ hostPlatform }: { hostPlatform?: PiPlatform }) {
const [activePlatform, setActivePlatform] = useState<PiPlatform>(); const [chosenPlatform, setActivePlatform] = useState<PiPlatform>();
const [focusedPlatform, setFocusedPlatform] = useState<PiPlatform>("linux"); const activePlatform = chosenPlatform ?? hostPlatform ?? "linux";
const tabRefs = useRef<Record<PiPlatform, HTMLButtonElement | null>>({ linux: null, macos: null, windows: null }); const tabRefs = useRef<Record<PiPlatform, HTMLButtonElement | null>>({ linux: null, macos: null, windows: null });
function activateAndFocus(platform: PiPlatform) { function activateAndFocus(platform: PiPlatform) {
setFocusedPlatform(platform);
setActivePlatform(platform); setActivePlatform(platform);
tabRefs.current[platform]?.focus(); tabRefs.current[platform]?.focus();
} }
@@ -242,13 +241,11 @@ function PiPlatformInstructions() {
role="tab" role="tab"
aria-selected={activePlatform === platform.id} aria-selected={activePlatform === platform.id}
aria-controls={`pi-${platform.id}-panel`} 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"} 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)} onKeyDown={(event) => handleTabKeyDown(event, platform.id)}
onClick={() => { onClick={() => {
setFocusedPlatform(platform.id); setActivePlatform(platform.id);
setActivePlatform((current) => current === platform.id ? undefined : platform.id);
}} }}
> >
{platform.label} {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> <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>} </nav>}
<div className={page ? "thot-pi-detail" : undefined}> <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"}> <div className={page ? undefined : "mt-5"} hidden={page && section !== "runtime"}>
{forbidden ? ( {forbidden ? (
<div role="alert" aria-label="Pi management unavailable" className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm"> <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)); 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) { @container work-area-panel (max-width: 45rem) {
.thot-workspace-manager__layout { .thot-workspace-manager__layout {
grid-template-columns: minmax(0, 1fr); 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(); 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", () => { test("uses the shared modeless work-area panel and keeps workspace content scrollable", () => {
renderManager(); 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 { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertCircle, AlertCircle,
@@ -76,6 +76,8 @@ type WorkspaceOperationGuard = Readonly<{
import { useWorkingContextScope } from "../workspaces/WorkingContext"; import { useWorkingContextScope } from "../workspaces/WorkingContext";
const workspaceAreas = ["Preparation", "Specific actions", "About", "Authentication"] as const;
export function WorkspaceManager({ export function WorkspaceManager({
open, open,
onClose, onClose,
@@ -96,6 +98,18 @@ export function WorkspaceManager({
canManageSecrets?: boolean; canManageSecrets?: boolean;
}) { }) {
const workingContext = useWorkingContextScope(); 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 queryClient = useQueryClient();
const [selectedId, setSelectedId] = useState<string | undefined>(initialWorkspaceId); const [selectedId, setSelectedId] = useState<string | undefined>(initialWorkspaceId);
const [secretValues, setSecretValues] = useState<Record<string, string>>({}); 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> <p className="font-mono text-xs text-muted-foreground">{selectedSummary.id}</p>
</div> </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.isLoading || runtimeQuery.isLoading) && <p className="text-sm text-muted-foreground">Loading workspace configuration…</p>}
{(detailQuery.isError || runtimeQuery.isError) && ( {(detailQuery.isError || runtimeQuery.isError) && (
@@ -539,6 +577,7 @@ export function WorkspaceManager({
{detailQuery.data && runtime && ( {detailQuery.data && runtime && (
<> <>
<div {...areaPanel("About")}>
<div className="max-w-3xl"> <div className="max-w-3xl">
<h3 className="font-heading text-lg font-semibold">About this workspace</h3> <h3 className="font-heading text-lg font-semibold">About this workspace</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground"> <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">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> <div><dt className="text-xs text-muted-foreground">Runtime status</dt><dd>{stateLabel(runtime.configurationState)}</dd></div>
</dl> </dl>
</div>
<div {...areaPanel("Specific actions")}>
<div> <div>
<h3 className="font-heading text-lg font-semibold">Workspace-specific actions</h3> <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> <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>
</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"> {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"> <div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="font-heading font-semibold">Authentication</h4> <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. Runtime secret controls are available only to authorized workspace operators.
</p> </p>
)} )}
</div>
</> </>
)} )}
</section> </section>
@@ -82,36 +82,41 @@
.thot-administration-dependency p { max-width: 72ch; margin-bottom: 0.5rem; } .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-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-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 .thot-workspace-manager__content { padding: 1.4rem; }
.thot-work-area-page__body--pi { padding: 1.5rem 1.75rem; } .thot-work-area-page__body--pi { display: flex; padding: 1.5rem 1.75rem; overflow: hidden; }
.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-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 { 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-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 { border-block-start: 1px solid oklch(var(--border)); padding-top: 1.25rem; }
.thot-workspace-preparation .thot-preprocessing-control { max-width: 40rem; } .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"] { 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"] :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-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-fleet-ledger__kpi-value { font-size: 1.125rem; }
.thot-administration-database .thot-administration-dependency { flex: none; } .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 { grid-template-columns: minmax(10rem, 0.55fr) minmax(0, 1.5fr); }
.thot-database-workbench form > .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .thot-database-workbench form > .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@container administration (max-width: 48rem) { @container administration (max-width: 48rem) {
.thot-administration-split, .thot-pi-workbench { grid-template-columns: minmax(0, 1fr); } .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-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, .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-index { max-height: 22rem; overflow-y: auto; }
.thot-administration-header { padding: 1.25rem; } .thot-administration-header { padding: 1.25rem; }
.thot-administration-split { margin: 1rem; } .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__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: 18rem; flex: none; border-inline-end: 0; border-block-end: 1px solid oklch(var(--border)); } .thot-work-area-page .thot-workspace-manager__navigation { max-height: 10rem; border-inline-end: 0; border-block-end: 1px solid oklch(var(--border)); }
.thot-work-area-page .thot-workspace-manager__content { overflow: visible; }
} }
@container administration (max-width: 30rem) { @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 h1 { font-size: 1.5rem; }
.thot-administration-header, .thot-administration-toolbar, .thot-work-area-page__body--pi { padding: 1rem; } .thot-administration-header, .thot-administration-toolbar, .thot-work-area-page__body--pi { padding: 1rem; }
.thot-administration-detail, .thot-pi-detail { 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 { WorkspacePreprocessingControl } from "../WorkspacePreprocessingControl";
import { AdministrationLink } from "./AdministrationNavigation"; import { AdministrationLink } from "./AdministrationNavigation";
export function WorkspacePreparation({ workspaceId, canManage, databaseSummary = false }: { export function WorkspacePreparation({ workspaceId, canManage }: {
workspaceId: string; canManage: boolean; databaseSummary?: boolean; workspaceId: string; canManage: boolean;
}) { }) {
const query = useQuery({ queryKey: ["workspace-preprocessing", workspaceId], const query = useQuery({ queryKey: ["workspace-preprocessing", workspaceId],
queryFn: () => getWorkspacePreprocessingStatus(workspaceId), enabled: canManage, retry: false, refetchInterval: 5000 }); 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 (!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"> return <section aria-label="Workspace readiness" className="thot-workspace-preparation">
<p className="thot-administration-eyebrow">Preparation</p> <p className="thot-administration-eyebrow">Preparation</p>
<h3 className="font-heading text-lg font-semibold mt-1">Ready for analysis</h3> <h3 className="font-heading text-lg font-semibold mt-1">Ready for analysis</h3>
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"sort" "sort"
"strconv" "strconv"
@@ -279,6 +280,7 @@ func renderCompose(installation config.Installation, fingerprint string) []byte
return []byte(fmt.Sprintf(`services: return []byte(fmt.Sprintf(`services:
core: core:
environment: environment:
THT_HOST_PLATFORM: %s
THT_MODEL_CATALOG_FILE: /run/thothii-model-catalog/catalog.json THT_MODEL_CATALOG_FILE: /run/thothii-model-catalog/catalog.json
THT_MODEL_CATALOG_REVISION: %s THT_MODEL_CATALOG_REVISION: %s
THT_DEFAULT_SESSION_MODEL: %s THT_DEFAULT_SESSION_MODEL: %s
@@ -306,7 +308,7 @@ func renderCompose(installation config.Installation, fingerprint string) []byte
embedding-model-init: embedding-model-init:
environment: environment:
OLLAMA_MODEL: %s OLLAMA_MODEL: %s
`, strconv.Quote(fingerprint), strconv.Quote(installation.ModelCatalog.Defaults.Interaction), `, strconv.Quote(runtime.GOOS), strconv.Quote(fingerprint), strconv.Quote(installation.ModelCatalog.Defaults.Interaction),
strconv.Quote(embedding.ID), strconv.Quote(embeddingModelName(embedding.ID)), strconv.Quote(strconv.Itoa(embedding.Dimensions)), strconv.Quote(embedding.ID), strconv.Quote(embeddingModelName(embedding.ID)), strconv.Quote(strconv.Itoa(embedding.Dimensions)),
strconv.Quote(installation.GeneratedModelCatalogPath()), strconv.Quote(installation.GeneratedPiModelsPath()), strconv.Quote(installation.GeneratedModelCatalogPath()), strconv.Quote(installation.GeneratedPiModelsPath()),
strconv.Quote(installation.GeneratedPiSettingsPath()), strconv.Quote(embedding.ID), strconv.Quote(installation.GeneratedPiSettingsPath()), strconv.Quote(embedding.ID),
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
@@ -50,6 +51,9 @@ func TestRenderProducesDeterministicCatalogPiAndComposeProjections(t *testing.T)
t.Fatalf("Pi projection must map canonical catalog keys to upstream model IDs") t.Fatalf("Pi projection must map canonical catalog keys to upstream model IDs")
} }
compose := string(first[ComposeFile]) compose := string(first[ComposeFile])
if !strings.Contains(compose, `THT_HOST_PLATFORM: "`+runtime.GOOS+`"`) {
t.Fatal("Compose must project the CLI host OS, not the Docker container OS")
}
for _, expected := range []string{"THT_MODEL_CATALOG_FILE", "THT_DEFAULT_SESSION_MODEL", "zai/glm-5.3", "THT_INTERNAL_EMBEDDING_ID", "THT_INTERNAL_EMBEDDING_MODEL", "OLLAMA_MODEL", installation.GeneratedModelCatalogPath()} { for _, expected := range []string{"THT_MODEL_CATALOG_FILE", "THT_DEFAULT_SESSION_MODEL", "zai/glm-5.3", "THT_INTERNAL_EMBEDDING_ID", "THT_INTERNAL_EMBEDDING_MODEL", "OLLAMA_MODEL", installation.GeneratedModelCatalogPath()} {
if !strings.Contains(compose, expected) { if !strings.Contains(compose, expected) {
t.Fatalf("Compose projection missing %q:\n%s", expected, compose) t.Fatalf("Compose projection missing %q:\n%s", expected, compose)