feat: establish unified administration and model context baseline

This commit is contained in:
Codex
2026-09-12 18:03:15 +02:00
parent 840344706f
commit f52bf22e05
74 changed files with 2334 additions and 523 deletions
+7 -2
View File
@@ -232,11 +232,16 @@ test("getMe fetches the typed authenticated principal", async () => {
});
test("resumeSession returns the typed runtime disposition", async () => {
server.use(http.post("/api/sessions/s1/resume", () =>
HttpResponse.json({ id: "s1", alreadyActive: false })));
workspacePreferences.save({ workspaceId: "different-workspace", provider: "local", model: "qwen", thinking: "medium" });
let body: unknown;
server.use(http.post("/api/sessions/s1/resume", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ id: "s1", alreadyActive: false });
}));
const result: { id: string; alreadyActive: boolean } = await resumeSession("s1");
expect(result).toEqual({ id: "s1", alreadyActive: false });
expect(body).toEqual({ provider: "local", model: "qwen" });
});
test("renameSession POSTs {name}", async () => {
+19 -6
View File
@@ -26,11 +26,12 @@ async function selectedPreferences(precondition?: AuthOperationPrecondition): Pr
// application memory from the installation defaults once, then keep choices ephemeral.
const legacy = await getSettings();
requireAuthOperationPrecondition(precondition);
const latest = workspacePreferences.load();
return workspacePreferences.save({
workspaceId: saved.workspaceId ?? legacy.workspace,
provider: saved.provider ?? legacy.provider,
model: saved.model ?? legacy.model,
thinking: saved.thinking ?? legacy.thinking,
workspaceId: latest.workspaceId ?? legacy.workspace,
provider: latest.provider ?? legacy.provider,
model: latest.model ?? legacy.model,
thinking: latest.thinking ?? legacy.thinking,
});
}
@@ -139,8 +140,20 @@ export const postSteer = (id: string, text: string) =>
export const closeSession = (id: string) =>
apiFetch<void>(`/sessions/${id}/close`, { method: "POST" });
export const resumeSession = (id: string) =>
apiFetch<ResumeSessionResult>(`/sessions/${id}/resume`, { method: "POST" });
export async function resumeSession(id: string) {
const operation = captureAuthOperation();
const precondition = operation ? {
operation,
isCurrent: () => isAuthOperationCurrent(operation, { sessionId: null, disposalEpoch: operation.disposalEpoch }),
} : undefined;
const selected = await selectedPreferences(precondition);
requireAuthOperationPrecondition(precondition);
return apiFetch<ResumeSessionResult>(`/sessions/${id}/resume`, {
method: "POST",
// The workspace is owned by the persisted session, never by this request.
body: JSON.stringify({ provider: selected.provider, model: selected.model }),
});
}
export const renameSession = (id: string, name: string) =>
apiFetch<void>(`/sessions/${id}/rename`, { method: "POST", body: JSON.stringify({ name }) });
+2
View File
@@ -160,6 +160,8 @@ export interface Principal {
export interface ResumeSessionResult {
id: string;
alreadyActive: boolean;
/** The session's pinned workspace; never supplied by the client. */
workspaceId?: string;
}
export interface SessionDocument {
@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { listDescriptionGenerationRuns } from "../api/catalog-databases";
import { hasPermission, useAuthUser } from "../auth/authState";
import { useSessionStore } from "../store/sessionStore";
/** Reuse the existing run cache across Core/Admin navigation; no background job manager. */
export function useInteractionModelBusy(): boolean {
const coreActive = useSessionStore((state) => state.agentActive);
const user = useAuthUser();
const { data: runs } = useQuery({
queryKey: ["description-generation-runs", 50],
queryFn: () => listDescriptionGenerationRuns(50),
enabled: hasPermission(user, "database.manage"),
retry: false,
refetchInterval: 5_000,
});
return coreActive || Boolean(runs?.some((run) => run.status === "queued" || run.status === "running"));
}
@@ -0,0 +1,259 @@
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { vi } from "vitest";
import { server } from "../test/msw";
import { setAuthState } from "../auth/authState";
import { useSessionStore } from "../store/sessionStore";
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
import { AppShell } from "./AppShell";
import { workspacePreferences } from "../workspaces/preferences";
import { FakeEventSource } from "../test/fakeEventSource";
const permissions = ["session.use", "database.manage", "workspace.manage", "workspace.secrets.manage", "memory.manage", "evidence.manage", "pi.manage"];
function renderShell(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
return render(<QueryClientProvider client={client}>
<AppShell canLogout={false} />
</QueryClientProvider>);
}
beforeEach(() => {
setAuthState({ issuer: "test", subject: "admin", roles: ["admin"], permissions, isAdmin: true, csrfToken: null, session: null });
localStorage.clear(); useSessionStore.getState().resetSession();
workspacePreferences.reset();
window.history.replaceState(null, "", "/");
FakeEventSource.instances = [];
(globalThis as any).EventSource = FakeEventSource;
server.use(
http.get("/api/sessions", () => HttpResponse.json([])),
http.get("/api/settings", () => HttpResponse.json({ workspace: "alpha", provider: "test", model: "test", thinking: "low" })),
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })),
http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("alpha"), workspaceSummaryFixture("beta")])),
http.get("/api/workspace-registry/status", () => HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false })),
http.get("/api/workspaces/:id/runtime-configuration", ({ params }) => HttpResponse.json({ workspaceId: params.id, revision: workspaceRevisionFixture(String(params.id)), configurationState: "ready", requirements: [] })),
http.get("/api/workspaces/:id", ({ params }) => HttpResponse.json({ workspace: canonicalWorkspaceFixture(String(params.id)), revision: workspaceRevisionFixture(String(params.id)) })),
http.get("/api/pi-management/status", () => HttpResponse.json({ ready: true, credentials: "present", config: { provider: "test", model: "test", reasoning: "low" } })),
http.get("/api/workspaces/:id/memory", () => HttpResponse.json({ items: [], total: 0, page: 1, page_size: 25 })),
http.get("/api/workspaces/:id/memory/pending", () => HttpResponse.json([])),
);
});
afterEach(() => vi.restoreAllMocks());
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();
expect(await screen.findByRole("main", { name: `${name} management` })).toBeVisible();
expect(screen.queryByRole("dialog", { name: `${name} management` })).not.toBeInTheDocument();
expect(window.location.hash).toBe("#host");
expect(window.history.state.portal).toBe("kept");
});
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" }));
act(() => window.history.back());
expect(await screen.findByRole("main", { name: "Workspace management" })).toBeVisible();
act(() => window.history.forward());
expect(await screen.findByRole("main", { name: "Pi management" })).toBeVisible();
expect(new URLSearchParams(window.location.search).get("lang")).toBe("it");
expect(window.location.pathname).toBe("/portal");
expect(window.location.hash).toBe("#host");
expect(window.history.state.portal).toEqual({ view: "thoth" });
});
test.each(["workspace", "database", "memory", "evidence", "pi"])("denies a direct %s link for a non-administrator", async name => {
setAuthState({ issuer: "test", subject: "reader", roles: ["user"], permissions: ["session.use"], isAdmin: false, csrfToken: null, session: null });
window.history.replaceState(null, "", `/?thoth_route=administration/${name}`);
renderShell();
expect(await screen.findByRole("main", { name: "Administration unavailable" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
});
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" }));
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");
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
act(() => window.history.back());
await waitFor(() => expect(new URLSearchParams(window.location.search).get("thoth_route")).toBe("administration/memory"));
expect(within(page).getByLabelText("Title")).toHaveValue("Keep this draft");
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
expect(page).toBeVisible();
expect(confirm).not.toHaveBeenCalled();
confirm.mockReturnValue(true);
await userEvent.click(within(page).getByRole("button", { name: "Cancel" }));
act(() => window.history.back());
expect(await screen.findByRole("main", { name: "Workspace management" })).toBeVisible();
act(() => window.history.forward());
expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible();
});
test("runs preprocessing for the globally selected workspace", async () => {
const targets: string[] = [];
server.use(http.post("/api/workspaces/:id/preprocessing", ({ params }) => {
targets.push(String(params.id));
return HttpResponse.json({ schemaVersion: 1, workspaceId: params.id, state: "ready", actionable: true, clearable: true, detail: "Prepared" });
}));
window.history.replaceState(null, "", "/?thoth_route=administration/workspace&thoth_workspace=beta");
workspacePreferences.selectWorkspace("beta");
renderShell();
const preparation = await screen.findByRole("region", { name: "Workspace readiness" });
await userEvent.click(await within(preparation).findByRole("button", { name: "Run again" }));
await waitFor(() => expect(targets).toEqual(["beta"]));
expect(within(preparation).getByRole("link", { name: "Open Database configuration and schema" })).toHaveAttribute("href", "/?thoth_route=administration%2Fdatabase&thoth_workspace=beta");
});
test("option A has one collapsible global context and preserves the Core draft across Admin", async () => {
renderShell();
const composer = await screen.findByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "Keep my original Core question");
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
expect(screen.getAllByRole("combobox", { name: "Workspace" })).toHaveLength(1);
expect(screen.getAllByRole("combobox", { name: "Model" })).toHaveLength(1);
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta");
expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" });
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" }));
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" }));
expect(screen.getByRole("textbox", { name: /new question/i })).toBe(composer);
expect(composer).toHaveValue("Keep my original Core question");
});
test("invalid remembered choices do not silently fall back and gate both Core and Admin", async () => {
workspacePreferences.selectWorkspace("removed");
workspacePreferences.selectModel("removed/model");
window.history.replaceState(null, "", "/?thoth_route=administration/memory");
renderShell();
await screen.findByText(/Unavailable saved choices are not replaced/);
expect(screen.getByRole("button", { name: "New session" })).toBeDisabled();
expect(screen.queryByRole("main", { name: "Memory management" })).not.toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "alpha");
expect(screen.queryByRole("main", { name: "Memory management" })).not.toBeInTheDocument();
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Model" }), "test/test");
expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible();
});
test("workspace and model stay locked during Core and unlock after it finishes", async () => {
renderShell();
await screen.findByRole("textbox", { name: /new question/i });
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
act(() => useSessionStore.getState().setAgentActive(true));
expect(screen.getByRole("combobox", { name: "Workspace" })).toBeDisabled();
expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled();
act(() => useSessionStore.getState().setAgentActive(false));
expect(screen.getByRole("combobox", { name: "Workspace" })).toBeEnabled();
expect(screen.getByRole("combobox", { name: "Model" })).toBeEnabled();
});
test("dirty Memory blocks workspace and model changes, including after a failed save", async () => {
let saveAttempts = 0;
server.use(
http.get("/api/models", () => HttpResponse.json({ models: [
{ provider: "test", id: "test", name: "Test LLM", reasoning: true },
{ provider: "test", id: "other", name: "Other LLM", reasoning: true },
] })),
http.post("/api/workspaces/:id/memory", () => { saveAttempts++; return HttpResponse.json({ code: "memory_unavailable", error: "Save failed" }, { status: 500 }); }),
);
window.history.replaceState(null, "", "/?thoth_route=administration/memory");
renderShell();
const page = await screen.findByRole("main", { name: "Memory management" });
await userEvent.click(within(page).getByRole("button", { name: "New card" }));
await userEvent.type(within(page).getByLabelText("Title"), "Do not lose this");
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta");
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Model" }), "test/other");
expect(workspacePreferences.load()).toMatchObject({ workspaceId: "alpha", model: "test" });
await userEvent.type(within(page).getByLabelText("Content"), "Retain this content.");
await userEvent.type(within(page).getByLabelText("Scope"), "This workspace.");
await userEvent.click(within(page).getByRole("button", { name: "Save card" }));
await waitFor(() => expect(saveAttempts).toBe(1));
expect(await within(page).findByRole("alert")).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
expect(page).toBeVisible();
expect(within(page).getByLabelText("Title")).toHaveValue("Do not lose this");
});
test("a failed catalog refresh retains a mounted Admin draft", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
window.history.replaceState(null, "", "/?thoth_route=administration/memory");
renderShell(client);
const page = await screen.findByRole("main", { name: "Memory management" });
await userEvent.click(within(page).getByRole("button", { name: "New card" }));
await userEvent.type(within(page).getByLabelText("Title"), "Still here");
server.use(http.get("/api/models", () => new HttpResponse(null, { status: 503 })));
await act(async () => { await client.refetchQueries({ queryKey: ["models"], exact: true }); });
expect(page).toBeVisible();
expect(within(page).getByLabelText("Title")).toHaveValue("Still here");
});
test("Resume binds the pinned workspace immediately and leaves the global model unchanged", async () => {
server.use(
http.get("/api/sessions", () => HttpResponse.json([{
id: "in-beta", status: "open", question: "Beta session", active: true,
created_at: "2026-09-12T00:00:00Z", archived: false,
}])),
http.post("/api/sessions/in-beta/resume", () => HttpResponse.json({ id: "in-beta", alreadyActive: true, workspaceId: "beta" })),
http.get("/api/sessions/in-beta", () => HttpResponse.json({ id: "in-beta", phase: 3, workspace_id: "beta", provider: "old", model: "old" })),
);
renderShell();
await screen.findByRole("textbox", { name: /new question/i });
await userEvent.click(await screen.findByTestId("session-item-in-beta"));
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" });
});
test("a successful Memory save releases the navigation guard and the context lock", async () => {
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
server.use(http.post("/api/workspaces/:id/memory", async ({ request }) => {
const card = await request.json() as object;
await gate;
return HttpResponse.json({ id: "saved", saved: true, indexed: true, action: "upsert",
card: { ...card, id: "saved", workspace_id: "alpha", revision: "1", origin: "manual", indexed: true } });
}));
window.history.replaceState(null, "", "/?thoth_route=administration/memory");
renderShell();
const page = await screen.findByRole("main", { name: "Memory management" });
await userEvent.click(within(page).getByRole("button", { name: "New card" }));
await userEvent.type(within(page).getByLabelText("Title"), "Saved card");
await userEvent.type(within(page).getByLabelText("Scope"), "This workspace");
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.click(within(page).getByRole("button", { name: "Save card" }));
expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
expect(page).toBeVisible();
await act(async () => release());
await within(page).findByText("Saved and indexed.");
expect(screen.getByRole("combobox", { name: "Model" })).toBeEnabled();
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
expect(await screen.findByRole("textbox", { name: /new question/i })).toBeVisible();
});
test("Admin generation locks the context and Core submission across page navigation", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderShell(client);
const composer = await screen.findByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "Wait for Admin to finish");
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await act(async () => {
client.setQueryData(["description-generation-runs", 50], [{ id: "r1", status: "running" }]);
});
await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled());
expect(screen.getByRole("combobox", { name: "Workspace" })).toBeDisabled();
expect(screen.getByRole("button", { name: /send/i })).toBeDisabled();
await act(async () => { client.setQueryData(["description-generation-runs", 50], []); });
await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toBeEnabled());
expect(screen.getByRole("button", { name: /send/i })).toBeEnabled();
});
+9 -5
View File
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { AppShell } from "./AppShell";
import { clearAuthState, getAuthGeneration, getAuthState, setAuthState, useAuthGeneration, useAuthUser } from "../auth/authState";
import { server } from "../test/msw";
import { workspaceSummaryFixture } from "../test/workspace-fixtures";
import { useSessionStore } from "../store/sessionStore";
import { workspacePreferences } from "../workspaces/preferences";
@@ -37,11 +38,11 @@ beforeEach(() => {
server.use(
http.get("/api/sessions", () => HttpResponse.json([])),
http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("default")])),
http.get("/api/workspace-registry/status", () => HttpResponse.json({
branch: "main", ahead: 0, behind: 0, degraded: false,
})),
http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })),
http.get("/api/health/dwh", () => HttpResponse.json({ ok: true })),
);
});
@@ -54,7 +55,9 @@ describe("authenticated shell permissions", () => {
await userEvent.click(screen.getByRole("button", { name: "Memory management" }));
const memory = screen.getByRole("main", { name: "Memory management" });
expect(memory).toBeVisible();
expect(within(memory).getByRole("combobox", { name: "Workspace" })).toBeVisible();
expect(within(memory).queryByRole("combobox", { name: "Workspace" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
expect(screen.getByRole("combobox", { name: "Workspace" })).toHaveValue("default");
});
test("does not use the legacy installation default as a preprocessing workspace", async () => {
const requestedWorkspaceIds: string[] = [];
@@ -98,6 +101,7 @@ describe("authenticated shell permissions", () => {
const administration = await screen.findByRole("button", { name: "Administration" });
await user.click(administration);
await user.selectOptions(await screen.findByRole("combobox", { name: "Workspace" }), "psd-clinical");
await waitFor(() => expect(requestedWorkspaceIds).toContain("psd-clinical"));
expect(requestedWorkspaceIds).not.toContain("local");
});
@@ -139,14 +143,14 @@ describe("authenticated shell permissions", () => {
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 preprocessing = within(panel).getByRole("region", { name: "Workspace preprocessing" });
expect(panel.children[0]).toBe(database);
expect(panel.children[1]).toBe(memory);
expect(panel.children[2]).toBe(evidence);
expect(panel.children[3]).toBe(separator);
expect(panel.children[4]).toBe(workspace);
expect(panel.children[5]).toBe(pi);
expect(panel.children[6]).toBe(preprocessing);
expect(within(panel).queryByRole("region", { name: "Workspace preprocessing" })).not.toBeInTheDocument();
expect(within(panel).getByText(/Workspace readiness/)).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "All sessions" })).toBeInTheDocument();
await user.keyboard(" ");
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw";
import { workspaceSummaryFixture } from "../test/workspace-fixtures";
import { FakeEventSource } from "../test/fakeEventSource";
import { useSessionStore } from "../store/sessionStore";
import { clearAuthState, setAuthState } from "../auth/authState";
@@ -51,8 +52,8 @@ beforeEach(() => {
model: "test",
thinking: "low",
})),
http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("default")])),
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })),
http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })),
http.get("/api/catalog/metrics", () => HttpResponse.json({
scope: "global",
@@ -214,10 +215,10 @@ test("hides the complete administrative navigation from a regular user", () => {
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
});
test("does not leave database management without confirming a dirty form", async () => {
test("requires saving or cancelling a dirty database form before leaving", async () => {
server.use(http.get("/api/catalog/databases", () => HttpResponse.json([{
id: "11111111-1111-4111-8111-111111111111",
workspaceId: "psd-clinical",
workspaceId: "default",
workspaceName: "Policlinico San Donato",
workspaceAvailable: true,
workspaceRevision: { commit: "a".repeat(40), blob: "b".repeat(40) },
@@ -250,19 +251,19 @@ test("does not leave database management without confirming a dirty form", async
await expandAdministration();
await userEvent.click(screen.getByRole("button", { name: "Database management" }));
await userEvent.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
const schema = screen.getByLabelText("Schema");
const schema = await screen.findByLabelText("Schema");
await userEvent.clear(schema);
await userEvent.type(schema, "reporting");
const newSession = screen.getByRole("button", { name: "New session" });
await userEvent.click(newSession);
expect(confirm).toHaveBeenCalledWith("Discard unsaved database changes and leave database management?");
expect(confirm).not.toHaveBeenCalled();
expect(screen.getByRole("main", { name: "Database management" })).toBeVisible();
expect(schema).toHaveValue("reporting");
confirm.mockReturnValue(true);
await userEvent.click(screen.getByRole("button", { name: "Back to list" }));
await userEvent.click(newSession);
await waitFor(() => expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument());
confirm.mockRestore();
@@ -43,7 +43,7 @@ beforeEach(() => {
snapshotPath: "/data/workspaces/default.yaml",
},
}])),
http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })),
);
});
@@ -60,7 +60,7 @@ test("New session starts prewarm without delaying composer focus", async () => {
await userEvent.click(screen.getByRole("button", { name: /^new session$/i }));
const composer = screen.getByRole("textbox", { name: /new question/i });
const composer = await screen.findByRole("textbox", { name: /new question/i });
await waitFor(() => expect(prewarmStarted).toBe(true));
await waitFor(() => expect(composer).toHaveFocus());
expect(composer).toHaveAttribute("data-awaiting-input", "true");
@@ -84,7 +84,7 @@ test("a known preprocessing requirement disables New session", async () => {
const newSession = screen.getByRole("button", { name: "New session" });
await waitFor(() => expect(newSession).toBeDisabled());
expect(newSession).toHaveAttribute("data-navigation-state", "unavailable");
expect(newSession).toHaveAttribute("title", "Catalog revision 18 is not indexed.");
await waitFor(() => expect(newSession).toHaveAttribute("title", "Catalog revision 18 is not indexed."));
});
test("records the prompt without central duplication, then opens the live log", async () => {
@@ -98,7 +98,7 @@ test("records the prompt without central duplication, then opens the live log",
);
renderShell();
const composer = screen.getByRole("textbox", { name: /new question/i });
const composer = await screen.findByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "How many patients?");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
@@ -126,7 +126,7 @@ test("a failed create restores the landing view and preserves the question for r
);
renderShell();
const composer = screen.getByRole("textbox", { name: /new question/i });
const composer = await screen.findByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "Keep this question");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
@@ -150,7 +150,7 @@ test("a DWH-unreachable precheck shows a specific alert and preserves the questi
);
renderShell();
const composer = screen.getByRole("textbox", { name: /new question/i });
const composer = await screen.findByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "quanti pazienti?");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
@@ -176,13 +176,15 @@ test("model selector shows the three Pi-enabled models and stores the selected p
);
renderShell();
const select = await screen.findByRole("combobox", { name: "Model" });
await screen.findByRole("textbox", { name: /new question/i });
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
const select = screen.getByRole("combobox", { name: "Model" });
await waitFor(() => {
expect(within(select).getAllByRole("option").map((option) => option.textContent)).toEqual([
"GLM-5.2", "DeepSeek V4 Flash", "Qwen3.6 35B A3B Local",
"Choose a model", "GLM-5.2", "DeepSeek V4 Flash", "Qwen3.6 35B A3B Local",
]);
});
await userEvent.selectOptions(select, "qwen3.6-35b-a3b");
await userEvent.selectOptions(select, "local-qwen/qwen3.6-35b-a3b");
await waitFor(() => expect(workspacePreferences.load()).toEqual({
workspaceId: "default", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low",
}));
@@ -205,7 +207,7 @@ test("opens Workspace management from the right sidebar without interrupting the
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
const dialog = screen.getByRole("dialog", { name: "Workspace management" });
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" });
@@ -214,17 +216,17 @@ test("opens Workspace management from the right sidebar without interrupting the
expect(sessionNavigation).not.toContainElement(dialog);
expect(screen.getByTestId("app-shell")).toHaveAttribute("data-activity-layout", "closed");
expect(workspaceManagement).toHaveAttribute("aria-current", "page");
expect(workspaceManagement).toHaveAttribute("aria-expanded", "true");
expect(screen.queryByRole("dialog", { name: "Workspace management" })).not.toBeInTheDocument();
expect(workspaceManagement).toHaveAttribute("data-navigation-state", "current");
expect(workspaceManagement).toHaveClass("bg-[oklch(var(--nav-active))]");
expect(newSession).toHaveAttribute("data-navigation-state", "available");
expect(newSession).not.toHaveAttribute("aria-current");
await userEvent.click(screen.getByRole("button", { name: "Close workspace management" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Workspace management" })).not.toBeInTheDocument());
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
await waitFor(() => expect(screen.queryByRole("main", { name: "Workspace management" })).not.toBeInTheDocument());
expect(newSession).toHaveAttribute("aria-current", "page");
expect(newSession).toHaveAttribute("data-navigation-state", "current");
expect(workspaceManagement).toHaveAttribute("aria-expanded", "false");
expect(new URLSearchParams(window.location.search).has("thoth_route")).toBe(false);
expect(workspaceManagement).toHaveAttribute("data-navigation-state", "available");
});
@@ -245,10 +247,10 @@ test("does not block the shell when the DWH is unavailable at startup", async ()
});
test("marks the composer as awaiting input for a pending freetext gate", () => {
test("marks the composer as awaiting input for a pending freetext gate", async () => {
useSessionStore.setState({
pendingWidget: { id: "free-1", widget: "freetext", title: "Clarify" },
});
renderShell();
expect(screen.getByRole("textbox", { name: /new question/i })).toHaveAttribute("data-awaiting-input", "true");
expect(await screen.findByRole("textbox", { name: /new question/i })).toHaveAttribute("data-awaiting-input", "true");
});
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw";
import { workspaceSummaryFixture } from "../test/workspace-fixtures";
import { FakeEventSource } from "../test/fakeEventSource";
import { AppShell } from "./AppShell";
import type { AuthenticatedUser } from "../api/types";
@@ -74,8 +75,8 @@ beforeEach(() => {
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false, permissions: ["session.use", "pi.manage"] }),
),
http.get("/api/settings", () => HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" })),
http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("psd")])),
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "zai", id: "glm-5.2", name: "Test LLM", reasoning: true }] })),
http.get("/api/sessions", () => HttpResponse.json(LIST)),
http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
http.get("/api/sessions/:id/documents", () => HttpResponse.json([
@@ -123,7 +124,7 @@ test("Pi management preserves the open session summary and the model activity ti
await user.click(screen.getByRole("button", { name: "Administration" }));
await user.click(screen.getByRole("button", { name: "Pi management" }));
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
const dialog = screen.getByRole("dialog", { name: "Pi management" });
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" });
@@ -131,27 +132,27 @@ test("Pi management preserves the open session summary and the model activity ti
expect(workArea).toContainElement(dialog);
expect(sessionNavigation).not.toContainElement(dialog);
expect(piManagement).toHaveAttribute("aria-current", "page");
expect(piManagement).toHaveAttribute("aria-expanded", "true");
expect(screen.queryByRole("dialog", { name: "Pi management" })).not.toBeInTheDocument();
expect(piManagement).toHaveAttribute("data-navigation-state", "current");
expect(newSession).toHaveAttribute("data-navigation-state", "available");
expect(newSession).not.toHaveAttribute("aria-current");
const preservedSummary = screen.getByRole("complementary", { name: "Session summary" });
expect(preservedSummary).not.toHaveAttribute("aria-hidden", "true");
const preservedSummary = document.querySelector('[aria-label="Session summary"]');
expect(preservedSummary).toHaveAttribute("aria-hidden", "true");
expect(preservedSummary).toHaveTextContent("Attiva uno");
await user.click(screen.getByRole("button", { name: "Close Pi management" }));
await user.click(screen.getByRole("button", { name: "Return to session" }));
await waitFor(() => {
expect(screen.getByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
});
expect(newSession).toHaveAttribute("aria-current", "page");
expect(newSession).toHaveAttribute("data-navigation-state", "current");
expect(piManagement).toHaveAttribute("aria-expanded", "false");
expect(new URLSearchParams(window.location.search).has("thoth_route")).toBe(false);
expect(piManagement).toHaveAttribute("data-navigation-state", "available");
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(await screen.findByRole("button", { name: "Close Pi management" }));
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" }));
expect(await screen.findByRole("heading", { name: "Model activity" })).toBeVisible();
@@ -5,6 +5,7 @@ import { beforeEach, expect, test, vi } from "vitest";
import { http, HttpResponse } from "msw";
import { AppShell } from "./AppShell";
import { server } from "../test/msw";
import { workspaceSummaryFixture } from "../test/workspace-fixtures";
import { FakeEventSource } from "../test/fakeEventSource";
import { clearAuthState, setAuthState } from "../auth/authState";
import { useSessionStore } from "../store/sessionStore";
@@ -32,8 +33,8 @@ beforeEach(() => {
server.use(
http.get("/api/me", () => HttpResponse.json({ issuer: "local", subject: "user-a", isAdmin: false })),
http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("default")])),
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })),
http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })),
);
});
@@ -91,7 +92,7 @@ test("a held new-session completion cannot replace the newer active s2 target",
http.get("/api/sessions/:id", ({ params }) => HttpResponse.json({ id: params.id, status: "open", phase: 1 })),
);
renderShell();
const composer = screen.getByRole("textbox", { name: /new question/i });
const composer = await screen.findByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "Held new question");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await createStarted.promise;
+121 -81
View File
@@ -20,7 +20,6 @@ import { DatabaseManagementPage } from "./DatabaseManagementPage";
import { MemoryManagementPage } from "./MemoryManagementPage";
import { EvidenceManagementPage } from "./EvidenceManagementPage";
import { ArchiveNavigation } from "./ArchiveNavigation";
import { WorkspacePreprocessingControl } from "./WorkspacePreprocessingControl";
import { Pencil, ArrowLeft, ArrowRight, ChevronDown, Trash2 } from "lucide-react";
import { Accordion } from "@base-ui/react/accordion";
import { Button, buttonVariants } from "../components/ui/button";
@@ -37,20 +36,23 @@ import {
} from "../components/ui/dialog";
import type { SessionScope, SessionSummary } from "../api/types";
import { useAuthGeneration, useAuthUser } from "../auth/authState";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useIsMutating, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent } from "react";
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation";
import { getSettings } from "../api/settings";
import { workspacePreferences, type WorkspacePreference } from "../workspaces/preferences";
import { workspacePreferences } from "../workspaces/preferences";
import { getWorkspacePreprocessingStatus } from "../api/workspace-preprocessing";
import { AdministrationNavigationProvider, useAdministrationNavigation } from "./administration/AdministrationNavigation";
import { AdministrationHeader } from "./administration/AdministrationPage";
import { WorkingContextProvider, useWorkingContext } from "../workspaces/WorkingContext";
import { WorkingContextShelf } from "./WorkingContextShelf";
import { useInteractionModelBusy } from "../models/useInteractionModelBusy";
interface AppShellProps {
canLogout: boolean;
}
type ActiveSurface = "core" | "database-management" | "memory-management" | "evidence-management";
type ActiveManagementPanel = "workspace" | "pi" | null;
const SESSION_SCOPES: readonly SessionScope[] = ["mine", "all"];
function sessionScopeTabClass(selected: boolean): string {
@@ -89,6 +91,10 @@ export function resolveDatabaseManagementPresentation({
}
export function AppShell({ canLogout }: AppShellProps) {
const workingContext = useWorkingContext();
const interactionBusy = useInteractionModelBusy();
const mutationBusy = useIsMutating() > 0;
const [adminBusy, setAdminBusy] = useState({ database: false, workspace: false, evidence: false, pi: false });
const authenticatedUser = useAuthUser();
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
const {
@@ -166,18 +172,9 @@ export function AppShell({ canLogout }: AppShellProps) {
queryKey: ["settings"],
queryFn: getSettings,
});
const [workspacePreference, setWorkspacePreference] = useState<WorkspacePreference>(
() => workspacePreferences.load(),
);
useEffect(() => {
const unsubscribe = workspacePreferences.subscribe(setWorkspacePreference);
// Child effects may seed the singleton before this parent effect subscribes.
setWorkspacePreference(workspacePreferences.load());
return unsubscribe;
}, []);
// Only an ID selected from the active Workspace Registry may address workspace-scoped APIs.
// Installation settings are a legacy hint and can name a removed/non-catalog workspace.
const selectedWorkspaceId = workspacePreference.workspaceId;
const selectedWorkspaceId = workingContext.workspace?.id;
const preprocessingQuery = useQuery({
queryKey: ["workspace-preprocessing", selectedWorkspaceId],
queryFn: () => getWorkspacePreprocessingStatus(selectedWorkspaceId!),
@@ -204,7 +201,6 @@ export function AppShell({ canLogout }: AppShellProps) {
const queryClient = useQueryClient();
const [showActivity, setShowActivity] = useState(false);
const [activeSurface, setActiveSurface] = useState<ActiveSurface>("core");
const databaseManagementPresentation = resolveDatabaseManagementPresentation({
isDevelopment: import.meta.env.DEV,
mode: import.meta.env.MODE,
@@ -214,8 +210,25 @@ export function AppShell({ canLogout }: AppShellProps) {
const databaseNavigationRef = useRef({ dirty: false, busy: false });
const updateDatabaseNavigationState = useCallback((state: { dirty: boolean; busy: boolean }) => {
databaseNavigationRef.current = state;
setAdminBusy(previous => previous.database === state.busy ? previous : { ...previous, database: state.busy });
}, []);
const [activeManagementPanel, setActiveManagementPanel] = useState<ActiveManagementPanel>(null);
const workspaceNavigationRef = useRef({ dirty: false, busy: false });
const updateWorkspaceNavigationState = useCallback((state: { dirty: boolean; busy: boolean }) => {
workspaceNavigationRef.current = state;
setAdminBusy(previous => previous.workspace === state.busy ? previous : { ...previous, workspace: state.busy });
}, []);
const { route, navigate } = useAdministrationNavigation(canLeaveDatabaseManagement, () => {
if (activeSurface === "memory-management") return memoryDirty || memoryBusy;
const state = activeSurface === "database-management" ? databaseNavigationRef.current
: activeSurface === "workspace-management" ? workspaceNavigationRef.current : null;
return Boolean(state && (state.dirty || state.busy));
});
const activeSurface = route.surface;
const [visited, setVisited] = useState<Set<string>>(() => new Set([activeSurface]));
useEffect(() => { setVisited(previous => previous.has(activeSurface) ? previous : new Set([...previous, activeSurface])); }, [activeSurface]);
const administrationBusy = mutationBusy || memoryBusy || Object.values(adminBusy).some(Boolean);
const contextLocked = interactionBusy || creatingSession || administrationBusy
|| preprocessingQuery.data?.state === "running";
const [adminNavigationValue, setAdminNavigationValue] = useState<string[]>([]);
const [activeOpen, setActiveOpen] = useState(true);
const [archiveOpen, setArchiveOpen] = useState(false);
@@ -306,30 +319,22 @@ export function AppShell({ canLogout }: AppShellProps) {
}
function canLeaveDatabaseManagement(): boolean {
if (activeSurface === "memory-management" && memoryBusy) {
toast.info("Wait for the Memory operation to finish before leaving this page");
const dirty = activeSurface === "workspace-management" ? workspaceNavigationRef.current.dirty
: activeSurface === "memory-management" ? memoryDirty
: activeSurface === "database-management" ? databaseNavigationRef.current.dirty : false;
if (dirty) {
toast.warning("Save your administration changes, or cancel the edit, before leaving this page.");
return false;
}
if (activeSurface === "memory-management" && memoryDirty) {
return window.confirm("Discard unsaved Memory changes and leave Memory management?");
}
if (activeSurface !== "database-management") return true;
if (databaseNavigationRef.current.busy) {
toast.info("Wait for the database operation to finish before leaving this page");
return false;
}
if (databaseNavigationRef.current.dirty) {
return window.confirm("Discard unsaved database changes and leave database management?");
}
return true;
}
function openPanel(id: string) {
if (!workingContext.ready) return;
if (!canLeaveDatabaseManagement()) return;
const s = sessions.find((x) => x.id === id);
if (!s) return;
setActiveSurface("core");
setActiveManagementPanel(null);
navigate({ surface: "core" }, true);
// A session with a live Pi runtime opens straight into its live view: doResume
// reconnects to the already-active runtime and replays its pending gate, so an
// in-progress session never shows an empty screen that reads as "stopped". Cold or
@@ -349,9 +354,9 @@ export function AppShell({ canLogout }: AppShellProps) {
});
}
async function doResume(id: string, databaseExitApproved = false) {
if (!workingContext.ready) return;
if (!databaseExitApproved && !canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel(null);
navigate({ surface: "core" }, true);
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
const token = ++resumeInvocationRef.current;
@@ -388,6 +393,9 @@ export function AppShell({ canLogout }: AppShellProps) {
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
const latest = latestResumeIntentRef.current;
if (latest?.token !== operation.latestToken || latest.id !== id) return;
// Bind context atomically with Resume, before displaying any session or Admin actions.
// Older servers omit this field; their manifest remains the compatibility fallback.
if (result.workspaceId) workspacePreferences.selectWorkspace(result.workspaceId);
const reconnectSameSession = activeSessionIdRef.current === id;
setPanelSession(null);
setAwaitingQuestion(false);
@@ -417,7 +425,7 @@ export function AppShell({ canLogout }: AppShellProps) {
// Paint the persisted re-entry phase while the replacement stream starts replaying.
// The manifest's `phase` is the 1-based current phase (1..8).
try {
const m = (await getSession(id)) as { phase?: number };
const m = (await getSession(id)) as { phase?: number; workspace_id?: string };
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
const latestAfterManifest = latestResumeIntentRef.current;
if (
@@ -425,6 +433,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|| latestAfterManifest.id !== id
|| activeSessionIdRef.current !== id
) return;
if (!result.workspaceId && m.workspace_id) workspacePreferences.selectWorkspace(m.workspace_id);
if (typeof m.phase === "number" && m.phase >= 1 && m.phase <= 8) {
setPhase(`F${m.phase}`);
}
@@ -557,7 +566,7 @@ export function AppShell({ canLogout }: AppShellProps) {
session={s}
groups={groups}
onResume={() => doResume(s.id)}
onView={() => setPanelSession(s)}
onView={() => { if (canLeaveDatabaseManagement()) { navigate({ surface: "core" }, true); setPanelSession(s); } }}
onRename={() => setRenameTarget(s)}
onMove={(g) => move(s, g)}
onNewGroup={() => newGroup(s)}
@@ -633,13 +642,13 @@ export function AppShell({ canLogout }: AppShellProps) {
}, [lastSystemEvent]);
function startNewSession() {
if (!workingContext.ready || administrationBusy) return;
if (preprocessingBlocksNewSession) {
toast.warning(preprocessingQuery.data?.detail ?? "Workspace preprocessing is required.");
return;
}
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel(null);
navigate({ surface: "core" }, true);
invalidateResumeIntent();
newSessionOperationRef.current = null;
resetSession();
@@ -707,13 +716,35 @@ export function AppShell({ canLogout }: AppShellProps) {
await logoutUser();
}
const currentNavigation = activeSurface === "database-management"
? "database"
: activeSurface === "memory-management" ? "memory" : activeSurface === "evidence-management" ? "evidence" : activeManagementPanel ?? "core";
const currentNavigation = activeSurface.replace("-management", "");
const administrationPermitted = activeSurface === "core" || (isAdmin && (
activeSurface === "workspace-management" ||
(activeSurface === "database-management" && canManageDatabase) ||
(activeSurface === "memory-management" && canManageMemory) ||
(activeSurface === "evidence-management" && canManageEvidence) ||
(activeSurface === "pi-management" && canManagePi)
));
const adminNavigationOpen = adminNavigationValue.includes("administration");
const managementNavigationCurrent = currentNavigation !== "core";
function changeContextWorkspace(id: string) {
if (contextLocked || !canLeaveDatabaseManagement() || id === workingContext.workspaceId) return;
invalidateResumeIntent();
setPanelSession(null);
selectActiveSession(null);
resetSession();
setAwaitingQuestion(false);
workspacePreferences.selectWorkspace(id);
}
function changeContextModel(id: string) {
if (!contextLocked && canLeaveDatabaseManagement()) workspacePreferences.selectModel(id);
}
return (
<WorkingContextProvider value={{ workspaceId: workingContext.workspaceId, ready: workingContext.ready, locked: contextLocked, administrationBusy: administrationBusy || (interactionBusy && !agentActive) }}>
<AdministrationNavigationProvider navigate={navigate}>
<div className="thot-context-layout">
<WorkingContextShelf context={workingContext} locked={contextLocked} onWorkspace={changeContextWorkspace} onModel={changeContextModel} />
<div
ref={containerRef}
style={panelWidthsStyle}
@@ -722,8 +753,7 @@ export function AppShell({ canLogout }: AppShellProps) {
data-activity-resizing={activityResizing}
data-session-resizing={sessionResizing}
className={[
"flex h-screen bg-background text-foreground",
["memory-management", "evidence-management"].includes(activeSurface) ? "max-md:pt-12" : "",
"thot-app-shell flex bg-background text-foreground",
activityResizing || sessionResizing ? "select-none cursor-col-resize" : "",
].join(" ")}
>
@@ -748,7 +778,7 @@ export function AppShell({ canLogout }: AppShellProps) {
</div>
</div>
)}
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} onOpenWorkspaceManager={() => setActiveManagementPanel("workspace")} hidden={activeSurface !== "core"} />}
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} onOpenWorkspaceManager={() => navigate({ surface: "workspace-management" })} hidden={activeSurface !== "core"} />}
{activeSurface === "core" && showActivity && desktopSplit && (
<div className="relative w-0 shrink-0">
<div
@@ -764,21 +794,29 @@ export function AppShell({ canLogout }: AppShellProps) {
{/* Conversation column */}
<WorkAreaPanelHost data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
{activeSurface === "memory-management" && (
<MemoryManagementPage canManage={canManageMemory} onDirtyChange={setMemoryDirty} onBusyChange={setMemoryBusy} />
{!workingContext.ready && <main className="thot-administration-page" aria-label="Working context required"><AdministrationHeader title="Choose your working context" description="Select an available workspace and AI model above to enable Core and Administration." /></main>}
{!administrationPermitted && <main className="thot-administration-page" aria-label="Administration unavailable">
<AdministrationHeader title="Administration unavailable" description="Your account does not have permission to open this page." />
</main>}
{workingContext.ready && isAdmin && canManageMemory && (activeSurface === "memory-management" || visited.has("memory-management")) && (
<div style={{ display: activeSurface === "memory-management" ? "contents" : "none" }}><MemoryManagementPage key={selectedWorkspaceId} initialWorkspaceId={selectedWorkspaceId} canManage={canManageMemory} onDirtyChange={setMemoryDirty} onBusyChange={setMemoryBusy} /></div>
)}
{activeSurface === "evidence-management" && <EvidenceManagementPage canManage={canManageEvidence} />}
{activeSurface === "database-management" && (
{workingContext.ready && isAdmin && canManageEvidence && (activeSurface === "evidence-management" || visited.has("evidence-management")) && <div style={{ display: activeSurface === "evidence-management" ? "contents" : "none" }}><EvidenceManagementPage key={selectedWorkspaceId} initialWorkspaceId={selectedWorkspaceId} canManage={canManageEvidence} /></div>}
{workingContext.ready && isAdmin && canManageDatabase && (activeSurface === "database-management" || visited.has("database-management")) && (
<div style={{ display: activeSurface === "database-management" ? "contents" : "none" }}>
<DatabaseManagementPage
key={selectedWorkspaceId}
initialWorkspaceId={selectedWorkspaceId}
canManage={canManageDatabase}
canManageSecrets={canManageWorkspaceSecrets}
onNavigationStateChange={updateDatabaseNavigationState}
presentation={databaseManagementPresentation}
/>
</div>
)}
<div
aria-hidden={activeSurface !== "core"}
className={activeSurface === "core" ? "contents" : "hidden"}
aria-hidden={activeSurface !== "core" || !workingContext.ready}
className={activeSurface === "core" && workingContext.ready ? "contents" : "hidden"}
>
{activeSessionId && (
<div className="relative shrink-0 border-b border-border/70 bg-background/80 px-6 py-2.5 backdrop-blur">
@@ -857,23 +895,29 @@ export function AppShell({ canLogout }: AppShellProps) {
</div>
</div>
</div>
<WorkspaceManager
open={activeManagementPanel === "workspace"}
onClose={() => setActiveManagementPanel(null)}
<div style={{ display: activeSurface === "workspace-management" && workingContext.ready ? "contents" : "none" }}><WorkspaceManager
key={selectedWorkspaceId}
page
initialWorkspaceId={selectedWorkspaceId}
open={workingContext.ready && isAdmin && (activeSurface === "workspace-management" || visited.has("workspace-management"))}
onClose={() => navigate({ surface: "core" })}
onNavigationStateChange={updateWorkspaceNavigationState}
canManageWorkspace={canManageWorkspace}
canManageSecrets={canManageWorkspaceSecrets}
/>
{canManagePi && (
<PiManagement
open={activeManagementPanel === "pi"}
onClose={() => setActiveManagementPanel(null)}
/>
canPreprocess={canManageDatabase}
/></div>
{isAdmin && canManagePi && (
<div style={{ display: activeSurface === "pi-management" && workingContext.ready ? "contents" : "none" }}><PiManagement
page
open={workingContext.ready && (activeSurface === "pi-management" || visited.has("pi-management"))}
onClose={() => navigate({ surface: "core" })}
/></div>
)}
</WorkAreaPanelHost>
{/* Right session rail */}
{(activeSurface !== "core" || !showActivity) && (
<ArchiveNavigation surface={activeSurface}>
{(
<ArchiveNavigation surface={activeSurface} forceDrawer={activeSurface === "core" && showActivity}>
<aside aria-label="Session navigation" className="flex w-64 shrink-0 flex-col bg-sidebar">
<div className="thot-session-navigation__header relative text-center">
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
@@ -899,15 +943,18 @@ export function AppShell({ canLogout }: AppShellProps) {
</div>
<div className="thot-session-navigation__primary-controls">
{activeSurface !== "core" && <Button variant="outline" size="sm" className="w-full" onClick={() => navigate({ surface: "core" })}>
Return to session
</Button>}
<Button
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
size="sm"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "core" ? "page" : undefined}
data-navigation-state={preprocessingBlocksNewSession
data-navigation-state={preprocessingBlocksNewSession || !workingContext.ready || administrationBusy
? "unavailable"
: currentNavigation === "core" ? "current" : "available"}
disabled={preprocessingBlocksNewSession}
disabled={preprocessingBlocksNewSession || !workingContext.ready || administrationBusy}
title={preprocessingBlocksNewSession ? preprocessingQuery.data?.detail : undefined}
onClick={startNewSession}
>
@@ -949,8 +996,7 @@ export function AppShell({ canLogout }: AppShellProps) {
title={canManageDatabase ? undefined : "Database management permission is required"}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveManagementPanel(null);
setActiveSurface("database-management");
navigate({ surface: "database-management" }, true);
}}
>
Database management
@@ -965,15 +1011,14 @@ export function AppShell({ canLogout }: AppShellProps) {
data-navigation-state={currentNavigation === "memory" ? "current" : "available"}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveManagementPanel(null);
setActiveSurface("memory-management");
navigate({ surface: "memory-management" }, true);
}}
>Memory management</Button>
<Button variant={currentNavigation === "evidence" ? "navigationActive" : "outline"}
size="sm" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "evidence" ? "page" : undefined}
disabled={!canManageEvidence} title={canManageEvidence ? undefined : "Evidence management permission is required"}
onClick={() => { if (!canLeaveDatabaseManagement()) return; setActiveManagementPanel(null); setActiveSurface("evidence-management"); }}
onClick={() => navigate({ surface: "evidence-management" })}
>Evidence management</Button>
<div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" />
<Button
@@ -981,12 +1026,10 @@ export function AppShell({ canLogout }: AppShellProps) {
size="sm"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "workspace" ? "page" : undefined}
aria-expanded={activeManagementPanel === "workspace"}
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel("workspace");
navigate({ surface: "workspace-management" }, true);
}}
>
Workspace management
@@ -996,25 +1039,19 @@ export function AppShell({ canLogout }: AppShellProps) {
size="sm"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "pi" ? "page" : undefined}
aria-expanded={activeManagementPanel === "pi"}
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
disabled={!canManagePi}
title={canManagePi ? undefined : "Pi management permission is required"}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel("pi");
navigate({ surface: "pi-management" }, true);
}}
>
Pi management
</Button>
<WorkspacePreprocessingControl
workspaceId={selectedWorkspaceId}
status={preprocessingQuery.data}
loading={preprocessingQuery.isLoading}
unavailable={preprocessingQuery.isError}
canManage={canManageDatabase}
/>
<Button variant="ghost" size="sm" className="w-full min-w-0 whitespace-normal" onClick={() => navigate({ surface: "workspace-management", workspace: selectedWorkspaceId })}>
Workspace readiness: {preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking"}
</Button>
</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
@@ -1241,6 +1278,9 @@ export function AppShell({ canLogout }: AppShellProps) {
/>
)}
</div>
</div>
</AdministrationNavigationProvider>
</WorkingContextProvider>
);
}
+25 -16
View File
@@ -1,23 +1,32 @@
import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react";
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "../components/ui/dialog";
const query = "(max-width: 767px)";
function subscribe(listener: () => void) {
const media = window.matchMedia(query);
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}
const isNarrow = () => window.matchMedia(query).matches;
/** Keep the archive content readable on phones, using the shared accessible dialog. */
export function ArchiveNavigation({ surface, children }: { surface: string; children: ReactNode }) {
const narrow = useSyncExternalStore(subscribe, isNarrow, () => false);
/** Measure the embedded application, which can be narrower than the viewport. */
export function ArchiveNavigation({ surface, forceDrawer = false, children }: { surface: string; forceDrawer?: boolean; children: ReactNode }) {
const marker = useRef<HTMLSpanElement>(null);
const [narrow, setNarrow] = useState(false);
const [open, setOpen] = useState(false);
useLayoutEffect(() => {
const shell = marker.current?.closest<HTMLElement>(".thot-app-shell");
if (!shell) return;
const update = () => {
const width = shell.getBoundingClientRect().width || window.innerWidth;
const collapsed = forceDrawer || width < (surface === "core" ? 768 : 1000);
setNarrow(collapsed);
shell.dataset.administrationNarrow = String(collapsed);
};
update();
const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(update) : null;
observer?.observe(shell);
window.addEventListener("resize", update);
return () => { observer?.disconnect(); window.removeEventListener("resize", update); delete shell.dataset.administrationNarrow; };
}, [surface, forceDrawer]);
useEffect(() => { setOpen(false); }, [surface, narrow]);
if (!narrow || !["memory-management", "evidence-management"].includes(surface)) return children;
return <Dialog open={open} onOpenChange={setOpen}>
<div className="fixed inset-x-0 top-0 z-30 flex h-12 items-center justify-between border-b bg-background px-4">
const anchor = <span ref={marker} hidden />;
if (!narrow) return <>{anchor}{children}</>;
return <>{anchor}<Dialog open={open} onOpenChange={setOpen}>
<div className="thot-administration-mobile-nav">
<span className="font-heading font-semibold">ThothII</span>
<DialogTrigger render={<Button variant="outline" size="sm" />}>Navigation</DialogTrigger>
</div>
@@ -26,5 +35,5 @@ export function ArchiveNavigation({ surface, children }: { surface: string; chil
<DialogDescription className="sr-only">Choose an administration page or a session.</DialogDescription>
<div className="min-h-0 overflow-y-auto [&>aside]:w-full">{children}</div>
</DialogContent>
</Dialog>;
</Dialog></>;
}
@@ -14,6 +14,11 @@ import type {
} from "../api/catalog-databases";
import { Toaster } from "../components/ui/sonner";
import { DatabaseManagementPage } from "./DatabaseManagementPage";
import { workspacePreferences } from "../workspaces/preferences";
import { ComposerFooter } from "./SteerInput";
import { useSessionStore } from "../store/sessionStore";
beforeEach(() => { localStorage.clear(); });
const noSecrets = {
password: false,
@@ -142,7 +147,7 @@ function makeDescriptionGenerationRun(
return {
id: "77777777-7777-4777-8777-777777777777",
databaseId: "11111111-1111-4111-8111-111111111111",
modelId: "local-qwen",
modelId: "local/qwen",
scope: "selected_columns",
language: "it",
status: "queued",
@@ -221,8 +226,8 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
updatedAt: "2026-08-31T09:00:00Z",
})),
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
);
renderPage({ rows: [makeDatabase()], presentation: "fleet" });
@@ -231,8 +236,8 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
expect(pageHeading).toBeVisible();
const applicationBar = pageHeading.closest("header");
expect(applicationBar).not.toBeNull();
expect(within(applicationBar!).getByRole("region", { name: "Catalog status" })).toBeVisible();
expect(within(applicationBar!).getByRole("group", { name: "Database management actions" })).toBeVisible();
expect(screen.getByRole("region", { name: "Catalog status" })).toBeVisible();
expect(screen.getByRole("group", { name: "Database management actions" })).toBeVisible();
expect(screen.queryByText("Thoth catalog · Fleet ledger")).not.toBeInTheDocument();
expect(screen.queryByText("Inspect physical metadata, curate descriptions and control catalog operations.")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Back to workspace" })).not.toBeInTheDocument();
@@ -241,8 +246,8 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
const summary = screen.getByRole("region", { name: "Fleet summary" });
expect(await within(summary).findByText("2,275")).toBeVisible();
expect(within(summary).getByText("75%")).toBeVisible();
const modelSelector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
expect(modelSelector).toHaveValue("local-qwen");
const modelSelector = await screen.findByRole("combobox", { name: "Global LLM model" });
expect(modelSelector).toHaveValue("local/qwen");
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
const tablesAction = within(databaseRow).getByRole("button", { name: "View tables for Policlinico San Donato" });
@@ -253,10 +258,10 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
const manager = screen.getByRole("main", { name: "Database management" });
await user.click(within(databaseRow).getByRole("button", { name: "Edit Policlinico San Donato" }));
const editDrawer = await screen.findByRole("dialog", { name: "Edit database" });
const editDrawer = await screen.findByRole("region", { name: "Edit database form" });
expect(manager).toContainElement(editDrawer);
expect(editDrawer).toHaveAttribute("aria-modal", "false");
await user.click(within(editDrawer).getByRole("button", { name: "Close database panel" }));
expect(screen.queryByRole("dialog", { name: "Edit database" })).not.toBeInTheDocument();
await user.click(within(editDrawer).getByRole("button", { name: "Back to list" }));
const restoredDatabaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(restoredDatabaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
@@ -264,8 +269,8 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
expect(within(actionPicker).getByRole("option", { name: /^Synchronize all(?:,|$)/ })).toBeVisible();
expect(within(actionPicker).queryByRole("option", { name: "Synchronize all columns" })).not.toBeInTheDocument();
await user.selectOptions(actionPicker, "generate-missing");
expect(screen.getAllByRole("combobox", { name: "Metadata-generation LLM model" })).toHaveLength(1);
expect(modelSelector).toHaveValue("local-qwen");
expect(screen.getAllByRole("combobox", { name: "Global LLM model" })).toHaveLength(1);
expect(modelSelector).toHaveValue("local/qwen");
});
@@ -471,7 +476,7 @@ test("offers catalog configuration directly on an unconfigured Fleet workspace",
name: "Configure catalog for Research laboratory",
}));
const drawer = await screen.findByRole("dialog", { name: "Configure catalog" });
const drawer = await screen.findByRole("region", { name: "Configure catalog form" });
expect(within(drawer).getByLabelText("Workspace")).toHaveAttribute("readonly");
expect(within(drawer).getByLabelText("Workspace")).toHaveValue("Research laboratory");
});
@@ -555,7 +560,7 @@ test("reopens database synchronization history when no run is active", async ()
await user.click(within(databaseRow).getByRole("button", {
name: "View Policlinico San Donato",
}));
await user.click(screen.getByRole("button", { name: "Sync history" }));
await user.click(screen.getByRole("button", { name: "View schema synchronization logs" }));
const drawer = await screen.findByRole("dialog", { name: "Schema synchronization" });
expect(drawer).toBeVisible();
@@ -567,22 +572,53 @@ test("reopens database synchronization history when no run is active", async ()
test("selects the configured metadata-description model by default", async () => {
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [
{ id: "openai-mini", label: "OpenAI Mini" },
{ id: "local-qwen", label: "Local Qwen" },
{ id: "openai/mini", label: "OpenAI Mini" },
{ id: "local/qwen", label: "Local Qwen" },
],
default: "local-qwen",
default: "local/qwen",
})));
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
await waitFor(() => expect(selector).toHaveValue("local-qwen"));
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
await waitFor(() => expect(selector).toHaveValue("local/qwen"));
});
test("Core and Admin share one canonical selection, including models with the same short ID", async () => {
const models = [{ id: "local/qwen", label: "Local Qwen" }, { id: "remote/qwen", label: "Remote Qwen" }];
server.use(
http.get("/api/catalog/databases", () => HttpResponse.json([makeDatabase()])),
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({ models, default: "local/qwen" })),
http.get("/api/models", () => HttpResponse.json({ models: models.map((m) => ({
provider: m.id.split("/")[0], id: "qwen", name: m.label, reasoning: false,
})) })),
http.get("/api/settings", () => HttpResponse.json({ provider: "local", model: "qwen" })),
);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><ComposerFooter /><DatabaseManagementPage canManage canManageSecrets /></QueryClientProvider>);
const admin = await screen.findByRole("combobox", { name: "Global LLM model" });
const core = await screen.findByRole("combobox", { name: "Model" });
await waitFor(() => expect(admin).toHaveValue("local/qwen"));
act(() => useSessionStore.getState().setAgentActive(true));
expect(core).toBeDisabled();
expect(admin).toBeDisabled();
act(() => useSessionStore.getState().setAgentActive(false));
act(() => client.setQueryData(["description-generation-runs", 50], [makeDescriptionGenerationRun()]));
await waitFor(() => expect(core).toBeDisabled());
await waitFor(() => expect(admin).toBeDisabled());
act(() => client.setQueryData(["description-generation-runs", 50], []));
await waitFor(() => expect(core).toBeEnabled());
await waitFor(() => expect(admin).toBeEnabled());
await userEvent.selectOptions(admin, "remote/qwen");
await waitFor(() => expect(core).toHaveValue("remote/qwen"));
await userEvent.selectOptions(core, "local/qwen");
await waitFor(() => expect(admin).toHaveValue("local/qwen"));
});
test("keeps both run-history buttons visible beside the metadata-description selector", async () => {
const user = userEvent.setup();
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
const toolbar = screen.getByRole("group", { name: "Database management controls" });
const metadataControls = screen.getByRole("group", { name: "Metadata description controls" });
const actions = screen.getByRole("group", { name: "Database management actions" });
@@ -622,22 +658,23 @@ test("keeps both run-history buttons visible beside the metadata-description sel
expect(within(suggestionDrawer).getByText("No sensitivity analysis runs yet.")).toBeVisible();
});
test("changes the metadata-description model in page-local state", async () => {
test("changes the global model shared with Core", async () => {
const user = userEvent.setup();
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [
{ id: "openai-mini", label: "OpenAI Mini" },
{ id: "local-qwen", label: "Local Qwen" },
{ id: "openai/mini", label: "OpenAI Mini" },
{ id: "local/qwen", label: "Local Qwen" },
],
default: "local-qwen",
default: "local/qwen",
})));
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
await waitFor(() => expect(selector).toHaveValue("local-qwen"));
await user.selectOptions(selector, "openai-mini");
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
await waitFor(() => expect(selector).toHaveValue("local/qwen"));
await user.selectOptions(selector, "openai/mini");
expect(selector).toHaveValue("openai-mini");
expect(selector).toHaveValue("openai/mini");
expect(workspacePreferences.load()).toMatchObject({ provider: "openai", model: "mini" });
});
test("explains application setup when no metadata-description model is configured", async () => {
@@ -647,7 +684,7 @@ test("explains application setup when no metadata-description model is configure
})));
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
expect(selector).toBeDisabled();
expect(await screen.findByText(
"No metadata-generation LLM model is configured for this installation.",
@@ -661,11 +698,11 @@ test("uses a safe disabled setup state when metadata-description models are unav
}, { status: 503 })));
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
await waitFor(() => expect(selector).toHaveDisplayValue("Model choices unavailable"));
expect(selector).toBeDisabled();
expect(screen.getByText(
"Metadata-generation LLM model choices are unavailable.",
"Global LLM model choices are unavailable.",
)).toBeVisible();
expect(screen.queryByText("internal model registry details")).not.toBeInTheDocument();
});
@@ -674,12 +711,12 @@ test.each([null, "missing-model"])(
"disables metadata-description selection when the public response has no usable default (%s)",
async (configuredDefault) => {
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "openai-mini", label: "OpenAI Mini" }],
models: [{ id: "openai/mini", label: "OpenAI Mini" }],
default: configuredDefault,
})));
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
await waitFor(() => expect(selector).toHaveDisplayValue("No usable model configured"));
expect(selector).toBeDisabled();
expect(screen.getByText(
@@ -691,7 +728,7 @@ test.each([null, "missing-model"])(
test("renders only the public metadata-generation model contract", async () => {
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{
id: "approved-model",
id: "approved/model",
label: "Approved model",
provider: "private-provider",
model: "private/upstream-model",
@@ -699,12 +736,12 @@ test("renders only the public metadata-generation model contract", async () => {
apiKeyEnv: "PRIVATE_MODEL_API_KEY",
credential: "raw-private-credential",
}],
default: "approved-model",
default: "approved/model",
})));
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
await waitFor(() => expect(selector).toHaveValue("approved-model"));
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
await waitFor(() => expect(selector).toHaveValue("approved/model"));
expect(within(selector).getAllByRole("option").map((option) => option.textContent))
.toEqual(["Approved model"]);
expect(document.body).not.toHaveTextContent(/private-provider|private\/upstream-model/);
@@ -766,8 +803,8 @@ test("discloses source sampling before starting Generate missing descriptions",
let startBody: unknown;
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.post("/api/catalog/databases/:databaseId/description-generation-runs", async ({ request }) => {
startBody = await request.json();
@@ -793,7 +830,7 @@ test("discloses source sampling before starting Generate missing descriptions",
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
await waitFor(() => expect(startBody).toEqual({
modelId: "local-qwen",
modelId: "local/qwen",
scope: "missing",
}));
expect(screen.queryByText(/replace existing generated descriptions/i)).not.toBeInTheDocument();
@@ -805,8 +842,8 @@ test("keeps Fleet Generate missing descriptions behind the source-data disclosur
let generationStarts = 0;
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => {
generationStarts += 1;
@@ -822,8 +859,8 @@ test("keeps Fleet Generate missing descriptions behind the source-data disclosur
"generate-missing",
);
await waitFor(() => expect(screen.getByRole("combobox", {
name: "Metadata-generation LLM model",
})).toHaveValue("local-qwen"));
name: "Global LLM model",
})).toHaveValue("local/qwen"));
await user.click(screen.getByRole("button", { name: "Run action" }));
expect(screen.getByRole("note", {
@@ -896,8 +933,8 @@ test("confirms generating all descriptions, supports cancel, and sends the datab
let startBody: unknown;
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.post("/api/catalog/databases/:databaseId/description-generation-runs", async ({ request }) => {
startBody = await request.json();
@@ -929,7 +966,7 @@ test("confirms generating all descriptions, supports cancel, and sends the datab
await user.click(screen.getByRole("button", { name: "Generate all descriptions" }));
await waitFor(() => expect(startBody).toEqual({
modelId: "local-qwen",
modelId: "local/qwen",
scope: "all",
}));
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
@@ -939,8 +976,8 @@ test("keeps the database selected and safely explains when no descriptions are e
const user = userEvent.setup();
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => HttpResponse.json({
code: "description_generation_no_eligible_targets",
@@ -985,8 +1022,8 @@ test.each([
});
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => (
HttpResponse.json(queuedRun, { status: 202 })
@@ -1036,8 +1073,8 @@ test.each([
reason: "database.manage is absent",
canManage: false,
rows: [makeDatabase()],
models: [{ id: "local-qwen", label: "Local Qwen" }],
configuredDefault: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
configuredDefault: "local/qwen",
selectedRows: 1,
},
{
@@ -1052,8 +1089,8 @@ test.each([
reason: "the Workspace Database is not configured",
canManage: true,
rows: [unconfigured],
models: [{ id: "local-qwen", label: "Local Qwen" }],
configuredDefault: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
configuredDefault: "local/qwen",
selectedRows: 1,
},
{
@@ -1067,8 +1104,8 @@ test.each([
workspaceName: "Radiology",
}),
],
models: [{ id: "local-qwen", label: "Local Qwen" }],
configuredDefault: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
configuredDefault: "local/qwen",
selectedRows: 2,
},
])("disables database-wide generation when $reason", async ({
@@ -1102,8 +1139,8 @@ test("disables database-wide generation while a description generation is active
const queuedRun = makeDescriptionGenerationRun({ scope: "missing" });
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => (
HttpResponse.json(queuedRun, { status: 202 })
@@ -1871,8 +1908,8 @@ test("starts description generation for multiple selected tables", async () => {
let startBody: unknown;
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([
patientsTable,
@@ -1903,7 +1940,7 @@ test("starts description generation for multiple selected tables", async () => {
await user.click(generate);
await waitFor(() => expect(startBody).toEqual({
modelId: "local-qwen",
modelId: "local/qwen",
scope: "selected_tables",
targetIds: [patientsTable.id, visitsTable.id],
}));
@@ -1919,7 +1956,7 @@ test("generates descriptions for every column in selected tables", async () => {
const queuedRun = makeDescriptionGenerationRun({ scope: "selected_columns", total: 1 });
let startBody: unknown;
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({ models: [{ id: "local-qwen", label: "Local Qwen" }], default: "local-qwen" })),
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({ models: [{ id: "local/qwen", label: "Local Qwen" }], default: "local/qwen" })),
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.get(`/api/catalog/databases/:databaseId/tables/${patientsTable.id}/columns`, () => HttpResponse.json([patientIdColumn])),
http.post("/api/catalog/databases/:databaseId/description-generation-runs", async ({ request }) => {
@@ -1938,7 +1975,7 @@ test("generates descriptions for every column in selected tables", async () => {
await user.click(await screen.findByRole("menuitem", { name: "Generate descriptions for all columns" }));
await waitFor(() => expect(startBody).toEqual({
modelId: "local-qwen",
modelId: "local/qwen",
scope: "selected_columns",
targetIds: [patientIdColumn.id],
}));
@@ -2059,8 +2096,8 @@ test("keeps selected tables when description generation cannot start", async ()
};
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([
patientsTable,
@@ -2113,8 +2150,8 @@ test("refreshes Catalog Tables and Catalog Columns after table generation comple
let tableReads = 0;
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.get("/api/catalog/databases/:databaseId/tables", () => {
tableReads += 1;
@@ -2320,10 +2357,10 @@ test("starts one selected column with the configured default model", async () =>
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [
{ id: "openai-mini", label: "OpenAI Mini" },
{ id: "local-qwen", label: "Local Qwen" },
{ id: "openai/mini", label: "OpenAI Mini" },
{ id: "local/qwen", label: "Local Qwen" },
],
default: "local-qwen",
default: "local/qwen",
})),
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([patientIdColumn])),
@@ -2353,7 +2390,7 @@ test("starts one selected column with the configured default model", async () =>
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
await waitFor(() => expect(startBody).toEqual({
modelId: "local-qwen",
modelId: "local/qwen",
scope: "selected_columns",
targetIds: [patientIdColumn.id],
}));
@@ -2373,8 +2410,8 @@ test("shows database sensitivity analysis only for a selection and rejects multi
});
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", () => {
suggestionCalls += 1;
@@ -2495,8 +2532,8 @@ test("requests database-level sensitivity analysis for the only selected databas
const run = makeSensitivityAnalysisRun({ scope: "all", total: 1 });
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.get("/api/catalog/sensitive-data-suggestion-runs", () => {
historyCalls += 1;
@@ -2539,8 +2576,8 @@ test("refetches sensitivity analysis history after a failed request", async () =
let historyCalls = 0;
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.get("/api/catalog/sensitive-data-suggestion-runs", () => {
historyCalls += 1;
@@ -2579,8 +2616,8 @@ test("requests sensitivity analysis only for selected tables", async () => {
let suggestionBody: unknown;
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([
patientsTable,
@@ -2650,8 +2687,8 @@ test("allows a human downgrade and saves only explicit sensitivity changes", asy
const patches: Array<{ columnId: string; body: unknown }> = [];
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.get(
@@ -2777,8 +2814,8 @@ test("polls a description run and renders its events in sequence order", async (
const eventAfters: number[] = [];
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([patientIdColumn])),
@@ -2814,7 +2851,7 @@ test("polls a description run and renders its events in sequence order", async (
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(within(drawer).getByText(/local-qwen/)).toBeVisible();
expect(within(drawer).getByText(/local[/]qwen/)).toBeVisible();
expect(within(drawer).getByText(/selected columns/)).toBeVisible();
expect(await within(drawer).findByRole("heading", { name: "Completed" }, { timeout: 4_000 })).toBeVisible();
expect(within(drawer).getByText("1", { selector: "[data-count='total']" })).toBeVisible();
@@ -2852,8 +2889,8 @@ test("refreshes Catalog Tables and Catalog Columns after column generation compl
let columns = [patientIdColumn];
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.get("/api/catalog/databases/:databaseId/tables", () => {
tableReads += 1;
@@ -2907,8 +2944,8 @@ test("keeps the selected column and shows a safe message when generation cannot
const user = userEvent.setup();
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([patientIdColumn])),
@@ -2938,13 +2975,13 @@ test("shows a basic failed run without exposing private model or provider fields
const user = userEvent.setup();
let columnReads = 0;
const queuedRun = {
...makeDescriptionGenerationRun({ modelId: "approved-model" }),
...makeDescriptionGenerationRun({ modelId: "approved/model" }),
provider: "private-provider",
apiKey: "sk-private-key",
};
const failedRun = {
...makeDescriptionGenerationRun({
modelId: "approved-model",
modelId: "approved/model",
status: "failed",
processed: 1,
failed: 1,
@@ -2963,13 +3000,13 @@ test("shows a basic failed run without exposing private model or provider fields
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{
id: "approved-model",
id: "approved/model",
label: "Approved model",
provider: "private-provider",
upstreamModel: "private/upstream-model",
apiKeyEnv: "PRIVATE_MODEL_API_KEY",
}],
default: "approved-model",
default: "approved/model",
})),
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => {
@@ -3022,8 +3059,8 @@ test("offers generation and consolidation for multiple selected columns", async
let startBody: unknown;
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => (
@@ -3063,7 +3100,7 @@ test("offers generation and consolidation for multiple selected columns", async
await user.click(generate);
await waitFor(() => expect(startBody).toEqual({
modelId: "local-qwen",
modelId: "local/qwen",
scope: "selected_columns",
targetIds: [patientIdColumn.id, patientNameColumn.id],
}));
@@ -3072,7 +3109,7 @@ test("offers generation and consolidation for multiple selected columns", async
});
test.each([
{ name: "database.manage is absent", canManage: false, models: [{ id: "local-qwen", label: "Local Qwen" }], configuredDefault: "local-qwen" },
{ name: "database.manage is absent", canManage: false, models: [{ id: "local/qwen", label: "Local Qwen" }], configuredDefault: "local/qwen" },
{ name: "no usable model is configured", canManage: true, models: [], configuredDefault: null },
])("disables selected table and column generation when $name", async ({ canManage, models, configuredDefault }) => {
const user = userEvent.setup();
@@ -3113,8 +3150,8 @@ test("disables another generation start while the selected-column run is active"
const queuedRun = makeDescriptionGenerationRun();
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
models: [{ id: "local/qwen", label: "Local Qwen" }],
default: "local/qwen",
})),
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([patientIdColumn])),
+58 -27
View File
@@ -8,6 +8,9 @@ import {
import { useQuery, useQueryClient } from "@tanstack/react-query";
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,
@@ -55,13 +58,13 @@ import { DatabaseTables } from "./database-management/DatabaseTables";
import { DatabaseRelationships } from "./database-management/DatabaseRelationships";
import { CatalogSyncDrawer } from "./database-management/CatalogSyncDrawer";
import { MetadataGenerationModelSelector } from "./database-management/MetadataGenerationModelSelector";
import { useWorkingContextScope } from "../workspaces/WorkingContext";
import { DescriptionGenerationDrawer } from "./database-management/DescriptionGenerationDrawer";
import { SensitivityAnalysisHistoryDrawer } from "./database-management/SensitivityAnalysisHistoryDrawer";
import { SensitiveDataReviewDrawer } from "./database-management/SensitiveDataReviewDrawer";
import {
FleetLedgerBreadcrumb,
FleetLedgerContent,
FleetLedgerDrawer,
FleetLedgerHeader,
FleetLedgerKpiStrip,
FleetLedgerRealStatus,
@@ -106,6 +109,7 @@ function readPersistedDatabaseScreen(): DatabaseScreen {
}
interface Props {
initialWorkspaceId?: string;
canManage: boolean;
canManageSecrets: boolean;
onNavigationStateChange?: (state: DatabaseNavigationState) => void;
@@ -157,11 +161,13 @@ function isDescriptionGenerationActive(run?: DescriptionGenerationRun | null): b
}
export function DatabaseManagementPage({
initialWorkspaceId,
canManage,
canManageSecrets,
onNavigationStateChange,
presentation = "legacy",
}: Props) {
const workingContext = useWorkingContextScope();
const queryClient = useQueryClient();
const {
data,
@@ -174,13 +180,14 @@ export function DatabaseManagementPage({
queryFn: listCatalogDatabases,
retry: false,
});
const rows = data ?? [];
const rows = (data ?? []).filter(row => !workingContext || row.workspaceId === workingContext.workspaceId);
const metadataModelsQuery = useQuery({
queryKey: ["metadata-generation-models"],
queryFn: listMetadataGenerationModels,
retry: false,
});
const metadataModels = metadataModelsQuery.data?.models ?? [];
const interactionBusy = useInteractionModelBusy();
const configuredMetadataDefault = metadataModelsQuery.data?.default ?? "";
const defaultMetadataModel = configuredMetadataDefault
&& metadataModels.some((model) => model.id === configuredMetadataDefault)
@@ -210,7 +217,11 @@ export function DatabaseManagementPage({
);
const [screen, setScreen] = useState<DatabaseScreen>(readPersistedDatabaseScreen);
const [selectedMetadataModel, setSelectedMetadataModel] = useState("");
const [interactionPreferences, setInteractionPreferences] = useState(() => workspacePreferences.load());
useEffect(() => workspacePreferences.subscribe(setInteractionPreferences), []);
const selectedMetadataModel = interactionPreferences.provider && interactionPreferences.model
? `${interactionPreferences.provider}/${interactionPreferences.model}` : defaultMetadataModel;
const setSelectedMetadataModel = (id: string) => { workspacePreferences.selectModel(id); };
const [draft, setDraft] = useState<DatabaseFormDraft | null>(null);
const [baseline, setBaseline] = useState("");
const [formSource, setFormSource] = useState<FormSource | null>(null);
@@ -256,7 +267,11 @@ export function DatabaseManagementPage({
const formHeadingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
setSelectedMetadataModel(defaultMetadataModel);
if (!defaultMetadataModel) return;
const slash = defaultMetadataModel.indexOf("/");
workspacePreferences.migrate({
provider: defaultMetadataModel.slice(0, slash), model: defaultMetadataModel.slice(slash + 1),
});
}, [defaultMetadataModel]);
const activeRow = screen.kind === "list"
@@ -286,7 +301,11 @@ export function DatabaseManagementPage({
const busy = busyAction !== null;
const catalogChildVisible = screen.kind === "tables" || screen.kind === "relationships";
const navigationDirty = catalogChildVisible ? tablesNavigationState.dirty : dirty;
const navigationBusy = catalogChildVisible ? tablesNavigationState.busy : busy;
const navigationBusy = busy || tablesNavigationState.busy
|| Boolean(observedActiveSensitivityAnalysisRun)
|| Boolean(observedActiveDescriptionGenerationRun)
|| rows.some(row => row.activeSyncRun && ["queued", "running", "awaiting_confirmation", "applying"].includes(row.activeSyncRun.state))
|| Boolean(activeSyncRun && ["queued", "running", "awaiting_confirmation", "applying"].includes(activeSyncRun.state));
useEffect(() => {
onNavigationStateChange?.({ dirty: navigationDirty, busy: navigationBusy });
@@ -392,6 +411,15 @@ 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]);
@@ -1037,7 +1065,7 @@ export function DatabaseManagementPage({
);
const fleetMetadataModelControl = (
<MetadataGenerationModelSelector
canManage={canManage}
canManage={canManage && !interactionBusy && !descriptionGenerationActive && !busyAction}
data={metadataModelsQuery.data}
isError={metadataModelsQuery.isError}
isLoading={metadataModelsQuery.isLoading}
@@ -1153,21 +1181,19 @@ export function DatabaseManagementPage({
if (presentation === "fleet") {
const fleetFormMode = formVisible ? screen.kind as DatabaseFormMode : null;
const fleetDrawer = formVisible && fleetFormMode ? (
<FleetLedgerDrawer
open
eyebrow="Catalog configuration"
title={databaseFormTitle(
fleetFormMode,
"drawer",
)}
description={`${activeRow.workspaceName} · ${activeRow.workspaceId}`}
onClose={backToList}
closeLabel="Close database panel"
busy={busy}
width="wide"
>
<div className="thot-administration-split thot-database-workbench">
<nav aria-label="Database workspaces" className="thot-administration-index p-4">
<p className="thot-administration-eyebrow mb-3">Workspace catalog</p>
{rows.map(row => <Button key={row.workspaceId} variant={row.workspaceId === activeRow.workspaceId ? "navigationActive" : "ghost"}
aria-current={row.workspaceId === activeRow.workspaceId ? "page" : undefined} disabled={busy}
className="w-full justify-start whitespace-normal mb-2" onClick={event => {
if (dirty && !window.confirm("Discard unsaved database changes?")) return;
openForm(row.configured ? "edit" : "configure", row, event.currentTarget);
}}>{databaseDisplayName(row)}</Button>)}
</nav>
<div className="min-w-0">
<DatabaseForm
presentation="drawer"
presentation="page"
mode={fleetFormMode}
row={activeRow}
draft={draft}
@@ -1198,7 +1224,9 @@ export function DatabaseManagementPage({
onOpenSensitiveHistory={openSensitivityReviewItemHistory}
activeSyncRun={currentActiveRun}
/>
</FleetLedgerDrawer>
<WorkspacePreparation workspaceId={activeRow.workspaceId} canManage={canManage} databaseSummary />
</div>
</div>
) : null;
const fleetContent = screen.kind === "tables" && tablesVisible ? (
@@ -1278,7 +1306,7 @@ export function DatabaseManagementPage({
return (
<>
<main aria-label="Database management" className="flex min-h-0 flex-1 overflow-hidden bg-background">
<main aria-label="Database management" className="thot-administration-page thot-administration-database">
<FleetLedgerShell
header={(
<FleetLedgerHeader
@@ -1309,8 +1337,8 @@ export function DatabaseManagementPage({
className="whitespace-nowrap"
aria-label="View schema synchronization logs"
title="View schema synchronization progress and history"
disabled={!canManage || (!selectedDatabaseId && !activeSyncRun?.databaseId)}
onClick={() => openSync(selectedDatabaseId ? rows.find((row) => row.id === selectedDatabaseId) : undefined)}
disabled={!canManage || (!activeRow && !selectedDatabaseId && !activeSyncRun?.databaseId)}
onClick={() => openSync(activeRow ?? (selectedDatabaseId ? rows.find((row) => row.id === selectedDatabaseId) : undefined))}
>
<RefreshCw aria-hidden="true" />
{currentActiveRun ? "Schema sync active" : "Schema sync logs"}
@@ -1373,10 +1401,13 @@ export function DatabaseManagementPage({
: undefined}
/>
)}
children={<FleetLedgerContent busy={isLoading || isFetching}>{fleetContent}</FleetLedgerContent>}
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={(
<>
{fleetDrawer}
{catalogDrawers}
</>
)}
@@ -1407,7 +1438,7 @@ export function DatabaseManagementPage({
className="flex flex-wrap items-end gap-3"
>
<MetadataGenerationModelSelector
canManage={canManage}
canManage={canManage && !interactionBusy && !descriptionGenerationActive && !busyAction}
data={metadataModelsQuery.data}
isError={metadataModelsQuery.isError}
isLoading={metadataModelsQuery.isLoading}
+12 -10
View File
@@ -6,6 +6,8 @@ import { useAuthGeneration } from "../auth/authState";
import { Button } from "../components/ui/button";
import { MarkdownView } from "../viewers/MarkdownView";
import { EvidenceSources } from "./EvidenceSourceReview";
import { AdministrationHeader } from "./administration/AdministrationPage";
import { useWorkingContextScope } from "../workspaces/WorkingContext";
const control = "rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-3 focus-visible:ring-ring/50";
const kinds = ["domain", "glossary", "enum", "example", "mapping", "normalization", "formula", "reference"];
@@ -32,22 +34,21 @@ function CopyText({ text, label, showText = true }: { text: string; label: strin
</div>;
}
export function EvidenceManagementPage({ canManage }: { canManage: boolean }) {
export function EvidenceManagementPage({ canManage, initialWorkspaceId }: { canManage: boolean; initialWorkspaceId?: string }) {
const context = useWorkingContextScope();
const generation = useAuthGeneration();
const [workspace, setWorkspace] = useState("");
const [workspace, setWorkspace] = useState(initialWorkspaceId ?? "");
const workspaces = useQuery({ queryKey: ["evidence-workspaces", generation],
queryFn: () => apiFetch<Array<{ id: string; displayName?: string }>>("/workspaces"), enabled: canManage });
if (!canManage) return <main className="p-6">Evidence administration is not permitted.</main>;
return <main aria-label="Evidence management" className="flex min-h-0 flex-1 flex-col overflow-auto bg-background">
<header className="flex flex-wrap items-end justify-between gap-4 border-b px-6 py-5">
<div><h1 className="font-serif text-2xl font-semibold">Evidence management</h1>
<p className="mt-1 text-sm text-muted-foreground">Review workspace knowledge and maintain its Markdown files.</p></div>
return <main aria-label="Evidence management" className="thot-administration-page">
<AdministrationHeader title="Evidence management" description="Review workspace knowledge and maintain its Markdown files." actions={!context &&
<label className="grid min-w-52 gap-1 text-sm">Workspace
<select className={control} value={workspace} onChange={event => setWorkspace(event.target.value)}>
<option value="">Choose a workspace</option>
{workspaces.data?.map(w => <option key={w.id} value={w.id}>{w.displayName ?? w.id}</option>)}
</select></label>
</header>
} />
{workspaces.isError && <p role="alert" className="p-6">{apiErrorMessage(workspaces.error)}</p>}
{workspace ? <WorkspaceEvidence key={`${generation}:${workspace}`} workspace={workspace} generation={generation} />
: <p className="p-8 text-muted-foreground">Choose a workspace to browse its Evidence and locate the files to edit.</p>}
@@ -79,8 +80,9 @@ function WorkspaceEvidence({ workspace, generation }: { workspace: string; gener
{list.isError && <p role="alert">{apiErrorMessage(list.error)}</p>}
{list.isPending && <p role="status">Loading Evidence files…</p>}
{list.data?.errors.map(error => <div key={error.file} role="alert" className="rounded-md border border-destructive/40 p-3 text-sm"><strong>{error.file}</strong><p>{error.message}</p><p>Correct this file before consolidation.</p></div>)}
<div className="grid min-h-0 gap-6 xl:grid-cols-[minmax(20rem,2fr)_minmax(25rem,3fr)]">
<section aria-label="Evidence list" className="min-w-0">
<div className="thot-administration-split" style={{ margin: 0 }}>
<section aria-label="Evidence list" className="thot-administration-index p-4">
<h2 className="thot-administration-eyebrow mb-3">Evidence library</h2>
<p className="mb-2 text-sm text-muted-foreground">{list.data?.total ?? 0} Evidence units</p>
<div className="overflow-auto"><table className="w-full text-left text-sm"><thead><tr className="border-b text-muted-foreground"><th className="py-2 pr-3 font-medium">Evidence</th><th className="pr-3 font-medium">Kind</th><th className="font-medium">Status</th></tr></thead>
<tbody>{list.data?.items.map(item => <tr key={item.id} className={`border-b ${selected === item.id ? "bg-muted" : ""}`}><td className="py-3 pr-3"><button className="text-left font-medium underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" onClick={() => setSelected(item.id)}>{item.title}</button><div className="mt-1 break-all text-xs text-muted-foreground">{item.id}</div></td><td className="pr-3 align-top pt-3">{item.kind}</td><td className="align-top pt-3">{states[item.status]}</td></tr>)}</tbody>
@@ -88,7 +90,7 @@ function WorkspaceEvidence({ workspace, generation }: { workspace: string; gener
{list.data?.total === 0 && <p className="py-6 text-sm text-muted-foreground">No Evidence matches these filters. Clear the filters or use the file instructions to create a unit.</p>}
<nav aria-label="Evidence pages" className="mt-4 flex items-center gap-3"><Button variant="outline" size="sm" disabled={page === 1 || list.isFetching} onClick={() => setPage(p => p-1)}>Previous</Button><span className="text-sm">Page {page}</span><Button variant="outline" size="sm" disabled={!list.data || page*25 >= list.data.total || list.isFetching} onClick={() => setPage(p => p+1)}>Next</Button></nav>
</section>
<section aria-label="Evidence detail" className="min-w-0">
<section aria-label="Evidence detail" className="thot-administration-detail">
{detail.isError ? <p role="alert">{apiErrorMessage(detail.error)}</p> : selected && detail.isPending ? <p role="status">Loading Evidence…</p> : detail.data?.item ? <UnitDetail item={detail.data.item} location={detail.data.location} /> : <p className="py-10 text-sm text-muted-foreground">Select an Evidence unit to read its complete content and provenance.</p>}
</section>
</div>
+14 -11
View File
@@ -7,6 +7,8 @@ import {
type MemoryCard, type MemoryFamily, type MemoryInput, type MemoryOutcome,
} from "../api/memory";
import { useAuthGeneration } from "../auth/authState";
import { AdministrationHeader } from "./administration/AdministrationPage";
import { useWorkingContextScope } from "../workspaces/WorkingContext";
const families: Record<MemoryFamily, string> = {
domain_clarification: "Domain clarification", sql_rule: "SQL rule",
@@ -19,10 +21,12 @@ function Input(props: InputHTMLAttributes<HTMLInputElement>) { return <input {..
const emptyCard = (): MemoryInput => ({ family: "domain_clarification", subject: "", detail: "",
scope: "", rationale: "", question: "", sql: "", concepts: [], dependencies: [], links: [] });
export function MemoryManagementPage({ canManage, onDirtyChange, onBusyChange }: {
export function MemoryManagementPage({ canManage, onDirtyChange, onBusyChange, initialWorkspaceId }: {
canManage: boolean; onDirtyChange: (dirty: boolean) => void; onBusyChange?: (busy: boolean) => void;
initialWorkspaceId?: string;
}) {
const [workspace, setWorkspace] = useState("");
const context = useWorkingContextScope();
const [workspace, setWorkspace] = useState(initialWorkspaceId ?? "");
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const generation = useAuthGeneration();
@@ -31,10 +35,8 @@ export function MemoryManagementPage({ canManage, onDirtyChange, onBusyChange }:
useEffect(() => { onDirtyChange(dirty); return () => onDirtyChange(false); }, [dirty, onDirtyChange]);
useEffect(() => { onBusyChange?.(busy); return () => onBusyChange?.(false); }, [busy, onBusyChange]);
if (!canManage) return <main aria-label="Memory management" className="p-6">Memory administration is not permitted.</main>;
return <main aria-label="Memory management" className="flex min-h-0 flex-1 flex-col overflow-auto bg-background">
<header className="flex shrink-0 flex-wrap items-end justify-between gap-4 border-b px-6 py-5">
<div><h1 className="font-serif text-2xl font-semibold">Memory management</h1>
<p className="mt-1 text-sm text-muted-foreground">Curate reusable knowledge for each workspace.</p></div>
return <main aria-label="Memory management" className="thot-administration-page">
<AdministrationHeader title="Memory management" description="Curate reusable knowledge for each workspace." actions={!context &&
<label className="grid min-w-52 gap-1 text-sm font-medium">Workspace
<select aria-label="Workspace" disabled={busy} className={control} value={workspace} onChange={event => {
if (dirty && !window.confirm("Discard unsaved Memory changes?")) return;
@@ -42,7 +44,7 @@ export function MemoryManagementPage({ canManage, onDirtyChange, onBusyChange }:
}}><option value="">Choose a workspace</option>
{workspaces.data?.map(w => <option value={w.id} key={w.id}>{w.displayName ?? w.id}</option>)}
</select></label>
</header>
} />
{workspaces.isError && <p role="alert" className="p-6">{apiErrorMessage(workspaces.error)}</p>}
{workspace ? <MemoryWorkspace key={`${generation}:${workspace}`} workspace={workspace} onDirtyChange={setDirty} onBusyChange={setBusy} />
: <p className="p-8 text-muted-foreground">Choose a workspace to browse its knowledge or create a Memory card.</p>}
@@ -113,7 +115,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
}
return <>
<div className="flex shrink-0 flex-wrap items-end gap-3 px-6 py-4">
<div className="thot-administration-toolbar">
<label className="grid min-w-52 flex-1 gap-1 text-sm">Search cards
<Input value={filters.q} onChange={e => filter("q", e.target.value)} placeholder="Text or card ID" /></label>
<label className="grid gap-1 text-sm">Family<select aria-label="Filter by family" className={control} value={filters.family} onChange={e => filter("family", e.target.value)}>
@@ -137,8 +139,9 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
</details>
{notice && <p role="status" className="border-b bg-muted/50 px-6 py-3 text-sm">{notice}</p>}
{error && <p role="alert" className="px-6 py-3 text-sm text-destructive">{error}</p>}
<div className="grid min-h-72 shrink-0 grid-cols-1 lg:grid-cols-[minmax(260px,0.8fr)_minmax(360px,1.2fr)]">
<section aria-label="Memory cards" className="min-w-0 border-b lg:border-r lg:border-b-0">
<div className="thot-administration-split">
<section aria-label="Memory cards" className="thot-administration-index">
<h2 className="thot-administration-section-label">Knowledge library</h2>
{list.isPending && <p role="status" className="p-6 text-sm">Loading Memory cards…</p>}
{list.isError && <div role="alert" className="p-6"><p>{apiErrorMessage(list.error)}</p><Button variant="outline" onClick={() => void list.refetch()}>Retry loading</Button></div>}
{list.data && <>
@@ -158,7 +161,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
</nav>
</>}
</section>
<section aria-label="Memory detail" className="min-w-0 px-6 py-5">
<section aria-label="Memory detail" className="thot-administration-detail">
{!draft && !selected && <p className="py-8 text-sm text-muted-foreground">Select a card to inspect its content, scope and provenance.</p>}
{selected && !draft && <div className="space-y-5">
<div><p className="text-xs text-muted-foreground">{families[selected.family]}</p><h2 className="mt-1 break-words font-serif text-xl font-semibold">{selected.subject}</h2></div>
+2 -2
View File
@@ -149,7 +149,7 @@ test("shows the single-source host workflow in accessible platform tabs", async
]);
for (const expected of [
"deploy/<installation-id>/thothii-installation.yaml",
"modelCatalog.defaults.session",
"modelCatalog.defaults.interaction",
"provider/model",
"generated projections",
"tht pi restart --yes --drain",
@@ -200,7 +200,7 @@ test("shows an explicit incomplete state when the catalog default is unavailable
const incomplete = await screen.findByRole("alert", { name: "Pi configuration incomplete" });
expect(incomplete).toHaveTextContent("The catalog default is unavailable");
expect(incomplete).toHaveTextContent("modelCatalog.defaults.session");
expect(incomplete).toHaveTextContent("modelCatalog.defaults.interaction");
expect(screen.getByRole("button", { name: "Test catalog default" })).toBeDisabled();
});
+21 -9
View File
@@ -28,7 +28,7 @@ function ReadinessRail({ ready, configured, credentials, smokeState }: {
<section
data-testid="pi-readiness-rail"
aria-label="Pi readiness sequence"
className="grid gap-3 border-l-2 border-border pl-4 sm:grid-cols-4"
className="grid gap-3 border-y border-border py-4 sm:grid-cols-2"
>
<RailItem label="Runtime" value={ready ? "Runtime ready" : "Runtime needs attention"} state={ready ? "ready" : "attention"} />
<RailItem label="Defaults" value={configured ? "Defaults ready" : "Defaults incomplete"} state={configured ? "ready" : "attention"} />
@@ -159,11 +159,11 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
</li>
<li>
<h4 className="font-semibold text-foreground">Choose eligibility and defaults</h4>
<p className="mt-1 text-muted-foreground">Use <code>modelCatalog.defaults.session</code> as the single default for new sessions. Add a <code>session</code> or <code>metadataGeneration</code> block to make a model eligible for that use. When metadata generation is present, set <code>defaults.metadataGeneration</code>. Runtime files such as Pi <code>models.json</code> and <code>settings.json</code> are generated projections and must not be edited.</p>
<p className="mt-1 text-muted-foreground">Use <code>modelCatalog.defaults.interaction</code> as the single installation-wide LLM default for Core and Administration. When Admin AI is configured, a selectable model needs both <code>session</code> and <code>metadataGeneration</code> blocks. The user's last model choice takes precedence and does not depend on workspace. Runtime files such as Pi <code>models.json</code> and <code>settings.json</code> are generated projections and must not be edited.</p>
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-1 text-muted-foreground">
<dt className="font-mono text-foreground">defaults.session </dt><dd>contains exactly one canonical provider/model identity.</dd>
<dt className="font-mono text-foreground">defaults.metadataGeneration </dt><dd>selects the metadata model when that use exists.</dd>
<dt className="font-mono text-foreground">defaults.interaction </dt><dd>contains exactly one canonical provider/model identity for every workspace.</dd>
</dl>
<p className="mt-2 text-muted-foreground">Before making each model available, verify an actual Core tool-call/review cycle through Pi and a small test description-generation run through LiteLLM. Configuration validation and Pi smoke tests alone do not certify both paths. Use approved test data; generation may incur provider charges.</p>
</li>
<li>
<h4 className="font-semibold text-foreground">Complete the provider setup</h4>
@@ -275,7 +275,8 @@ function PiPlatformInstructions() {
);
}
export function PiManagement({ open, onClose }: { open: boolean; onClose: () => void }) {
export function PiManagement({ open, onClose, page = false }: { open: boolean; onClose: () => void; page?: boolean }) {
const [section, setSection] = useState<"runtime" | "instructions">("runtime");
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<Feedback>();
const [smokeState, setSmokeState] = useState<SmokeState>();
@@ -330,6 +331,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
return (
<WorkAreaPanel
open={open}
mode={page ? "page" : "panel"}
ariaLabel="Pi management"
width="expanded"
eyebrow="Installation controls"
@@ -337,9 +339,17 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
description="Review the bundled runtime, inspect the catalog default, and test its provider configuration."
onClose={onClose}
closeLabel="Close Pi management"
bodyClassName={page ? "thot-work-area-page__body--pi" : undefined}
>
<PiPlatformInstructions />
<div className="mt-5">
<div className={page ? "thot-pi-workbench" : undefined}>
{page && <nav aria-label="Pi sections" className="thot-pi-index">
<p className="thot-administration-eyebrow mb-3">Installation</p>
<Button variant={section === "runtime" ? "navigationActive" : "ghost"} className="w-full justify-start mb-2" aria-current={section === "runtime" ? "page" : undefined} onClick={() => setSection("runtime")}>Runtime and models</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>}
<div className={page ? "thot-pi-detail" : undefined}>
{(!page || section === "instructions") && <PiPlatformInstructions />}
<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">
<p className="font-semibold">Pi management is not permitted</p>
@@ -377,7 +387,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
<section aria-label="Installation catalog default" className="border-t border-border/70 pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div><h3 className="font-heading text-sm font-semibold">Installation catalog default</h3><p className="mt-1 text-sm text-muted-foreground">This value is read from <code>modelCatalog.defaults.session</code>. Change the installation YAML and reload Pi; this page never creates a second model default.</p></div>
<div><h3 className="font-heading text-sm font-semibold">Installation catalog default</h3><p className="mt-1 text-sm text-muted-foreground">This value is read from <code>modelCatalog.defaults.interaction</code>. Change the installation YAML and reload Pi; this page never creates a second model default.</p></div>
</div>
{catalogReady ? (
<>
@@ -394,7 +404,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
<div className="mt-4">
<div role="alert" aria-label="Pi configuration incomplete" className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
<p className="font-semibold">The catalog default is unavailable.</p>
<p className="mt-1">Fix <code>modelCatalog.defaults.session</code> in the installation YAML, reload Pi, then retry this panel.</p>
<p className="mt-1">Fix <code>modelCatalog.defaults.interaction</code> in the installation YAML, reload Pi, then retry this panel.</p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled><FlaskConical />Test catalog default</Button>
@@ -453,6 +463,8 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
</div>
)}
</div>
</div>
</div>
</WorkAreaPanel>
);
}
+1 -1
View File
@@ -353,7 +353,7 @@ test("switching workspaces preserves a globally available catalog model", async
const workspaceSelector = await screen.findByRole("combobox", { name: "Workspace" });
await waitFor(() => expect(workspaceSelector).toHaveTextContent("psd-clinical"));
await userEvent.selectOptions(workspaceSelector, "psd-clinical");
await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toHaveValue("deepseek-v4-pro"));
await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toHaveValue("deepseek/deepseek-v4-pro"));
expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent("DeepSeek V4 Pro");
await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q");
+24 -16
View File
@@ -12,6 +12,8 @@ import {
WorkspaceSelectionError, workspacePolicyGate, workspacePreferences, type WorkspacePreference,
} from "../workspaces/drafts";
import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperation";
import { useInteractionModelBusy } from "../models/useInteractionModelBusy";
import { useWorkingContextScope } from "../workspaces/WorkingContext";
const THINKING_LEVELS = ["low", "medium", "high"] as const;
@@ -44,6 +46,7 @@ export function SteerInput({
awaitingInput = false,
working = false,
}: Props) {
const context = useWorkingContextScope();
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);
@@ -76,7 +79,7 @@ export function SteerInput({
async function submit() {
const trimmed = text.trim();
if (!trimmed || busy) return;
if (!trimmed || busy || (context && (!context.ready || context.administrationBusy))) return;
const operation = captureAuthOperation({ sessionId, disposalEpoch: operationEpochRef.current });
if (!operation) return;
setBusy(true);
@@ -164,7 +167,7 @@ export function SteerInput({
<button
type="button"
onClick={submit}
disabled={!text.trim() || busy}
disabled={!text.trim() || busy || Boolean(context && (!context.ready || context.administrationBusy))}
aria-label="Send"
title="Send"
className="grid size-7 shrink-0 place-items-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
@@ -180,6 +183,8 @@ export function SteerInput({
* Right: live context usage plus ephemeral workspace/model/thinking selectors.
*/
export function ComposerFooter() {
const globalContext = useWorkingContextScope();
const interactionBusy = useInteractionModelBusy();
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings });
const { data: workspaces = [], isLoading: workspacesLoading, isError: workspaceSummariesError } = useQuery({
queryKey: ["workspaces"], queryFn: listWorkspaces,
@@ -188,6 +193,7 @@ export function ComposerFooter() {
const models = modelsData?.models ?? [];
const tokenUsage = useSessionStore((state) => state.tokenUsage);
const [preferences, setPreferences] = useState<WorkspacePreference>(() => workspacePreferences.load());
useEffect(() => workspacePreferences.subscribe(setPreferences), []);
const selectableWorkspaces = workspaces.filter((workspace) => workspace.revision);
const registryDefaultWorkspaceId = settings?.workspace
&& selectableWorkspaces.some((workspace) => workspace.id === settings.workspace)
@@ -197,24 +203,27 @@ export function ComposerFooter() {
: undefined;
useEffect(() => {
if (!settings || workspacesLoading || workspaceSummariesError) return;
if (globalContext || !settings || workspacesLoading || workspaceSummariesError) return;
setPreferences(workspacePreferences.migrate({
workspaceId: registryDefaultWorkspaceId,
provider: settings.provider,
model: settings.model,
thinking: settings.thinking,
}));
}, [registryDefaultWorkspaceId, settings, workspaceSummariesError, workspacesLoading]);
}, [globalContext, registryDefaultWorkspaceId, settings, workspaceSummariesError, workspacesLoading]);
const workspace = preferences.workspaceId ?? registryDefaultWorkspaceId ?? "";
const selectedWorkspace = workspaces.find((candidate) => candidate.id === workspace);
const selectedWorkspaceHasRevision = Boolean(selectedWorkspace?.revision);
const model = preferences.model ?? settings?.model ?? "";
const model = preferences.provider && preferences.model
? `${preferences.provider}/${preferences.model}`
: settings?.provider && settings.model ? `${settings.provider}/${settings.model}` : "";
const thinking = preferences.thinking ?? settings?.thinking ?? "medium";
const policyModels = models;
useEffect(() => {
if (globalContext) return;
if (!workspace) {
workspacePolicyGate.clear();
} else if (workspacesLoading) {
@@ -229,7 +238,7 @@ export function ComposerFooter() {
} else {
workspacePolicyGate.allowLegacy(workspace);
}
}, [selectedWorkspace, workspace, workspaceSummariesError, workspacesLoading]);
}, [globalContext, selectedWorkspace, workspace, workspaceSummariesError, workspacesLoading]);
function update(patch: WorkspacePreference) {
if (patch.workspaceId && patch.workspaceId !== workspace) {
@@ -243,11 +252,10 @@ export function ComposerFooter() {
}
function onModelChange(id: string) {
const m = policyModels.find((x) => x.id === id);
update({ model: id, provider: m?.provider });
setPreferences(workspacePreferences.selectModel(id));
}
const knownModel = policyModels.some((m) => m.id === model);
const knownModel = policyModels.some((m) => `${m.provider}/${m.id}` === model);
const showModelFallback = !knownModel;
const contextPct = tokenUsage && tokenUsage.contextWindow > 0
? tokenUsage.totalTokens / tokenUsage.contextWindow
@@ -256,7 +264,7 @@ export function ComposerFooter() {
return (
<div className="flex items-center justify-between gap-2 border-t border-border/60 px-3 py-1.5">
<div className="flex items-center gap-2">
<FooterSelect aria-label="Workspace" value={workspace} onChange={(v) => update({ workspaceId: v })}>
{!globalContext && <FooterSelect aria-label="Workspace" value={workspace} disabled={interactionBusy} onChange={(v) => update({ workspaceId: v })}>
{workspaces.length === 0 ? (
<option value="">— workspace —</option>
) : (
@@ -266,7 +274,7 @@ export function ComposerFooter() {
</option>
))
)}
</FooterSelect>
</FooterSelect>}
<span
className="whitespace-nowrap font-mono text-[0.7rem] tabular-nums text-muted-foreground"
aria-label="Input, cached, and output tokens"
@@ -276,21 +284,21 @@ export function ComposerFooter() {
</div>
<div className="flex items-center gap-2">
<FooterSelect aria-label="Model" value={model} onChange={onModelChange}>
{!globalContext && <FooterSelect aria-label="Model" value={model} disabled={interactionBusy} onChange={onModelChange}>
{policyModels.length === 0 ? (
<option value={model}>{model || "— model —"}</option>
) : (
<>
{showModelFallback && <option value={model}>{model || "— model —"}</option>}
{policyModels.map((m) => (
<option key={`${m.provider}/${m.id}`} value={m.id}>
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
{m.name}
</option>
))}
</>
)}
</FooterSelect>
<FooterSelect aria-label="Thinking level" value={thinking} onChange={(v) => update({ thinking: v })} className="capitalize">
</FooterSelect>}
<FooterSelect aria-label="Thinking level" value={thinking} disabled={interactionBusy} onChange={(v) => update({ thinking: v })} className="capitalize">
{THINKING_LEVELS.map((l) => (
<option key={l} value={l}>
{l}
@@ -315,7 +323,7 @@ function FooterSelect({
onChange: (value: string) => void;
children: React.ReactNode;
className?: string;
} & Pick<React.SelectHTMLAttributes<HTMLSelectElement>, "aria-label">) {
} & Pick<React.SelectHTMLAttributes<HTMLSelectElement>, "aria-label" | "disabled">) {
return (
<select
value={value}
+33
View File
@@ -7,6 +7,39 @@
container-type: inline-size;
}
.thot-work-area-page {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
color: oklch(var(--foreground));
background: oklch(var(--background));
}
.thot-work-area-page__header {
display: flex;
flex: none;
gap: 1rem;
align-items: flex-start;
justify-content: space-between;
padding: 1.35rem clamp(1rem, 3vw, 2rem) 1.15rem;
border-bottom: 1px solid oklch(var(--primary-foreground) / 0.18);
color: oklch(var(--primary-foreground));
background-color: oklch(var(--primary));
background-image: none;
box-shadow: inset 0 -1px 0 oklch(var(--primary-foreground) / 0.18);
}
.thot-work-area-page__body {
min-width: 0;
min-height: 0;
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
}
.thot-work-area-panel {
position: absolute;
z-index: 50;
+30 -3
View File
@@ -16,6 +16,7 @@ import { X } from "lucide-react";
import { Button } from "../components/ui/button";
import "./WorkAreaPanel.css";
import { AdministrationHeader } from "./administration/AdministrationPage";
function classes(base: string, className?: string) {
return className ? `${base} ${className}` : base;
@@ -116,6 +117,7 @@ export function useModelessPanel(open: boolean, onClose: () => void, busy = fals
export interface WorkAreaPanelProps {
open: boolean;
mode?: "panel" | "page";
ariaLabel?: string;
eyebrow?: ReactNode;
title: ReactNode;
@@ -140,6 +142,7 @@ export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
function WorkAreaPanel(
{
open,
mode = "panel",
ariaLabel,
eyebrow,
title,
@@ -160,7 +163,7 @@ export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
const panelHost = useContext(WorkAreaPanelHostContext);
const titleId = useId();
const descriptionId = useId();
const panelRef = useModelessPanel(open, onClose, busy);
const panelRef = useModelessPanel(mode === "panel" && open, onClose, busy);
const assignRef = (node: HTMLElement | null) => {
panelRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
@@ -169,7 +172,31 @@ export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
if (!open) return null;
const panel = (
const panel = mode === "page" ? (
<main
ref={assignRef}
className={classes("thot-work-area-page", className)}
data-work-area-page=""
aria-label={ariaLabel}
aria-busy={busy}
>
<AdministrationHeader title={title} description={description} />
<div
className={classes("thot-work-area-page__body", bodyClassName)}
data-work-area-panel-region="body"
>
{children}
</div>
{footer ? (
<footer
className={classes("thot-work-area-panel__footer", footerClassName)}
data-work-area-panel-region="footer"
>
{footer}
</footer>
) : null}
</main>
) : (
<aside
ref={assignRef}
role="dialog"
@@ -234,6 +261,6 @@ export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
</aside>
);
return panelHost ? createPortal(panel, panelHost) : panel;
return mode === "panel" && panelHost ? createPortal(panel, panelHost) : panel;
},
);
@@ -0,0 +1,28 @@
.thot-context-layout { display: flex; flex-direction: column; min-width: 0; height: var(--thoth-app-height, 100dvh); overflow: hidden; }
.thot-context-layout > .thot-app-shell { flex: 1; height: auto; min-height: 0; }
.thot-context-shelf { flex: none; min-width: 0; max-height: 60%; overflow: auto; border-bottom: 1px solid oklch(var(--border)); background: oklch(var(--muted)/.5); container: working-context / inline-size; }
.thot-context-trigger { display: flex; align-items: center; gap: 16px; width: 100%; min-height: 72px; padding: 14px 24px; text-align: left; }
.thot-context-summary { min-width: 0; flex: 1; }
.thot-context-summary small { display: block; font: 600 .625rem/1.6 var(--font-mono); color: oklch(var(--muted-foreground)); letter-spacing: .065em; }
.thot-context-summary strong { display: block; font: 600 .875rem/1.6 var(--font-sans); overflow-wrap: anywhere; }
.thot-context-summary strong span { padding-inline: 12px; color: oklch(var(--muted-foreground)); }
.thot-context-action { display: flex; align-items: center; gap: 8px; color: oklch(var(--primary)); font-size: .75rem; font-weight: 600; }
.thot-context-trigger[aria-expanded=true] .thot-context-action > svg:last-child { transform: rotate(180deg); }
.thot-context-body { padding: 0 24px 20px; }
.thot-context-body[hidden] { display: none; }
.thot-context-explanation { max-width: none; margin: 0 0 20px; font-size: .8125rem; line-height: 1.6; color: oklch(var(--muted-foreground)); }
.thot-context-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 24px; }
.thot-context-fields label { display: grid; min-width: 0; gap: 7px; font-size: .8125rem; }
.thot-context-fields label > span { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.thot-context-fields select { min-width: 0; width: 100%; padding: 9px 12px; border: 1px solid oklch(var(--border)); border-radius: 6px; background: oklch(var(--card)); }
.thot-context-fields small, .thot-context-body > p[role] { color: oklch(var(--muted-foreground)); font-size: .75rem; line-height: 1.6; }
.thot-context-body > p[role] { margin-top: 12px; }
.thot-context-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.thot-context-shelf :is(button,select):focus-visible { outline: 2px solid oklch(var(--ring)); outline-offset: 3px; }
.thot-context-shelf select:disabled { opacity: .65; }
@container working-context (max-width: 38rem) {
.thot-context-fields { grid-template-columns: minmax(0,1fr); gap: 14px; }
.thot-context-trigger { padding: 12px 16px; gap: 10px; }
.thot-context-body { padding: 0 16px 16px; }
.thot-context-action > span { display: none; }
}
@@ -0,0 +1,40 @@
import { useState } from "react";
import { ChevronDown, Cpu, Layers, LockKeyhole, SlidersHorizontal } from "lucide-react";
import { Button } from "../components/ui/button";
import type { useWorkingContext } from "../workspaces/WorkingContext";
import "./WorkingContextShelf.css";
export function WorkingContextShelf({ context, locked, onWorkspace, onModel }: {
context: ReturnType<typeof useWorkingContext>; locked: boolean;
onWorkspace: (id: string) => void; onModel: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const open = expanded || !context.ready;
return <section className="thot-context-shelf" aria-label="Global working context">
<button className="thot-context-trigger" aria-expanded={open} aria-controls="thoth-working-context"
onClick={() => setExpanded(!open)}>
<SlidersHorizontal size={18} aria-hidden />
<span className="thot-context-summary"><small>WORKING CONTEXT</small><strong>{context.workspace?.displayName ?? "Choose workspace"}<span aria-hidden> / </span>{context.model?.name ?? "Choose model"}</strong></span>
<span className="thot-context-action">{locked && <LockKeyhole size={15} aria-hidden />}<span>{locked ? "Locked" : open ? "Close" : "Change context"}</span><ChevronDown size={16} aria-hidden /></span>
</button>
<div id="thoth-working-context" hidden={!open} className="thot-context-body">
<p className="thot-context-explanation">Your last choices take precedence over installation defaults. Workspace and model are remembered independently.</p>
<div className="thot-context-fields">
<label><span><Layers size={15} aria-hidden />Workspace</span>
<select aria-label="Workspace" value={context.workspace ? context.workspaceId : ""} disabled={locked || context.loading} onChange={event => onWorkspace(event.target.value)}>
<option value="" disabled>Choose a workspace</option>{context.workspaces.map(workspace => <option key={workspace.id} value={workspace.id}>{workspace.displayName}</option>)}
</select><small>{context.origins.workspace}</small>
</label>
<label><span><Cpu size={15} aria-hidden />AI interaction model</span>
<select aria-label="Model" value={context.model ? context.modelId : ""} disabled={locked || context.loading} onChange={event => onModel(event.target.value)}>
<option value="" disabled>Choose a model</option>{context.models.map(model => <option key={`${model.provider}/${model.id}`} value={`${model.provider}/${model.id}`}>{model.name}</option>)}
</select><small>{context.origins.model}</small>
</label>
</div>
{locked && <p role="status">Workspace and model are locked until the current operation finishes.</p>}
{context.ready && context.error && <p role="status">Context refresh failed. Your current choices and unsaved work have been retained.</p>}
{!context.ready && <p role="status">{context.loading ? "Loading working context…" : context.error ? "Working context is unavailable. Retry or ask the installation operator to check configuration." : "Select an available workspace and model to enable Core and Administration. Unavailable saved choices are not replaced automatically."}</p>}
<div className="thot-context-footer">{context.error && <Button variant="outline" onClick={context.retry}>Retry context</Button>}{context.ready && <Button variant="outline" onClick={() => setExpanded(false)}>Done</Button>}</div>
</div>
</section>;
}
+26 -5
View File
@@ -33,6 +33,7 @@ import {
} from "../auth/authOperation";
import { Button } from "../components/ui/button";
import { WorkAreaPanel } from "./WorkAreaPanel";
import { WorkspacePreparation } from "./administration/WorkspacePreparation";
import "./WorkspaceManager.css";
@@ -73,19 +74,30 @@ type WorkspaceOperationGuard = Readonly<{
context: WorkspaceOperationContext;
}>;
import { useWorkingContextScope } from "../workspaces/WorkingContext";
export function WorkspaceManager({
open,
onClose,
page = false,
initialWorkspaceId,
onNavigationStateChange,
canPreprocess = false,
canManageWorkspace = false,
canManageSecrets = false,
}: {
open: boolean;
onClose: () => void;
page?: boolean;
initialWorkspaceId?: string;
onNavigationStateChange?: (state: { dirty: boolean; busy: boolean }) => void;
canPreprocess?: boolean;
canManageWorkspace?: boolean;
canManageSecrets?: boolean;
}) {
const workingContext = useWorkingContextScope();
const queryClient = useQueryClient();
const [selectedId, setSelectedId] = useState<string>();
const [selectedId, setSelectedId] = useState<string | undefined>(initialWorkspaceId);
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
const [notice, setNotice] = useState<string>();
const [diagnostics, setDiagnostics] = useState<string[]>([]);
@@ -202,7 +214,7 @@ export function WorkspaceManager({
queryFn: () => guardedQuery(listWorkspaces),
enabled: open,
});
const workspaces = workspacesQuery.data ?? [];
const workspaces = (workspacesQuery.data ?? []).filter(workspace => !workingContext || workspace.id === workingContext.workspaceId);
const selectedSummary = useMemo(
() => workspaces.find(({ id }) => id === selectedId),
[selectedId, workspaces],
@@ -224,11 +236,13 @@ export function WorkspaceManager({
};
const selectWorkspace = (id: string) => {
if (page && (busyAction || (hasEnteredSecrets && !window.confirm("Discard unsaved workspace secrets?")))) return;
invalidateWorkspaceContext();
setSelectedId(id);
};
const showLevelOne = () => {
if (page && (busyAction || (hasEnteredSecrets && !window.confirm("Discard unsaved workspace secrets?")))) return;
invalidateWorkspaceContext();
setSelectedId(undefined);
};
@@ -386,10 +400,15 @@ export function WorkspaceManager({
: undefined;
const runtime = runtimeQuery.data;
const hasEnteredSecrets = Object.values(secretValues).some((value) => value.length > 0);
useEffect(() => {
onNavigationStateChange?.({ dirty: open && hasEnteredSecrets, busy: open && Boolean(busyAction) });
return () => onNavigationStateChange?.({ dirty: false, busy: false });
}, [open, hasEnteredSecrets, busyAction, onNavigationStateChange]);
return (
<WorkAreaPanel
open={open}
mode={page ? "page" : "panel"}
ariaLabel="Workspace management"
width="expanded"
eyebrow="Workspace configuration"
@@ -408,7 +427,7 @@ export function WorkspaceManager({
className="mb-3 w-full justify-start"
onClick={showLevelOne}
>
<ArrowLeft />Back to Level 1
<ArrowLeft />{page ? "Repository overview" : "Back to Level 1"}
</Button>
<p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Available workspaces</p>
<div className="min-h-0 flex-1 overflow-y-auto">
@@ -455,7 +474,7 @@ export function WorkspaceManager({
{!selectedSummary ? (
<section data-testid="workspace-overview" className="mx-auto max-w-3xl space-y-5">
<div>
<p className="thot-label">Level 1 · Repository</p>
<p className="thot-label">Repository</p>
<h2 className="font-heading text-xl font-semibold">What workspace files tell ThothII</h2>
</div>
<div className="grid gap-3 text-sm leading-6 text-muted-foreground">
@@ -506,11 +525,13 @@ export function WorkspaceManager({
) : (
<section className="mx-auto max-w-4xl space-y-5">
<div className="border-b border-border/70 pb-4">
<p className="thot-label">Level 2 · Selected workspace</p>
<p className="thot-label">Selected workspace</p>
<h2 className="font-heading text-xl font-semibold">{selectedSummary.displayName}</h2>
<p className="font-mono text-xs text-muted-foreground">{selectedSummary.id}</p>
</div>
{page && selectedId && <WorkspacePreparation workspaceId={selectedId} canManage={canPreprocess} />}
{(detailQuery.isLoading || runtimeQuery.isLoading) && <p className="text-sm text-muted-foreground">Loading workspace configuration…</p>}
{(detailQuery.isError || runtimeQuery.isError) && (
<QueryError name="Workspace configuration failed" message="Could not load this workspace configuration." retryLabel="Retry workspace configuration" onRetry={() => { void Promise.all([detailQuery.refetch(), runtimeQuery.refetch()]); }} />
@@ -166,12 +166,14 @@ export function WorkspacePreprocessingControl({
loading,
unavailable,
canManage,
presentation = "rail",
}: {
workspaceId?: string;
status?: WorkspacePreprocessingStatus;
loading: boolean;
unavailable: boolean;
canManage: boolean;
presentation?: "rail" | "page";
}) {
const queryClient = useQueryClient();
const [confirmingClear, setConfirmingClear] = useState(false);
@@ -225,7 +227,8 @@ export function WorkspacePreprocessingControl({
workspaceId
&& status?.actionable
&& canManage
&& !mutation.isPending,
&& !mutation.isPending
&& !clearMutation.isPending,
);
const clearable = Boolean(
workspaceId
@@ -240,7 +243,8 @@ export function WorkspacePreprocessingControl({
<section
aria-label="Workspace preprocessing"
aria-busy={state === "running" || loading || clearMutation.isPending}
className="mt-0.5 min-w-0 max-w-full border-t border-border/90 px-0.5 pt-2"
className="thot-preprocessing-control mt-0.5 min-w-0 max-w-full border-t border-border/90 px-0.5 pt-2"
data-presentation={presentation}
>
<p className="mb-2 px-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.08em] text-primary">
Preprocessing
@@ -0,0 +1,108 @@
import { createContext, useContext, useEffect, useRef, useState, type AnchorHTMLAttributes, type ReactNode } from "react";
export type AdministrationSurface = "database-management" | "memory-management" | "evidence-management" | "workspace-management" | "pi-management";
export type ActiveSurface = "core" | AdministrationSurface;
export interface AdministrationRoute { surface: ActiveSurface; workspace?: string }
const surfaces: Record<AdministrationSurface, string> = {
"database-management": "database", "memory-management": "memory", "evidence-management": "evidence",
"workspace-management": "workspace", "pi-management": "pi",
};
function readRoute(): AdministrationRoute {
const params = new URLSearchParams(window.location.search);
const entry = Object.entries(surfaces).find(([, name]) => params.get("thoth_route") === `administration/${name}`);
return { surface: entry ? entry[0] as AdministrationSurface : "core", workspace: entry ? params.get("thoth_workspace") || undefined : undefined };
}
function hrefFor(route: AdministrationRoute): string {
const url = new URL(window.location.href);
if (route.surface === "core") url.searchParams.delete("thoth_route");
else url.searchParams.set("thoth_route", `administration/${surfaces[route.surface]}`);
if (route.surface !== "core" && route.workspace) url.searchParams.set("thoth_workspace", route.workspace);
else url.searchParams.delete("thoth_workspace");
return `${url.pathname}${url.search}${url.hash}`;
}
interface HistoryPosition { index: number; key: string }
function position(state: unknown): HistoryPosition | undefined {
const value = (state as { thothNavigation?: HistoryPosition } | null)?.thothNavigation;
return value && typeof value.index === "number" && typeof value.key === "string" ? value : undefined;
}
/** Own only Thoth's query and history namespace, leaving the host's URL/state intact. */
export function useAdministrationNavigation(canLeave: () => boolean, hasUnsavedWork: () => boolean) {
const [route, setRoute] = useState<AdministrationRoute>(readRoute);
const policy = useRef({ canLeave, hasUnsavedWork });
policy.current = { canLeave, hasUnsavedWork };
const current = useRef({ route, href: window.location.href, state: window.history.state });
const restoring = useRef(false);
useEffect(() => {
const existing = position(window.history.state);
const state = { ...window.history.state, thothNavigation: existing ?? { index: 0, key: crypto.randomUUID() } };
window.history.replaceState(state, "", window.location.href);
current.current = { route: readRoute(), href: window.location.href, state };
const onPopState = () => {
if (restoring.current) { restoring.current = false; return; }
const next = readRoute();
const previous = current.current;
const changingPage = next.surface !== previous.route.surface || next.workspace !== previous.route.workspace;
if (changingPage && !policy.current.canLeave()) {
const from = position(previous.state);
const to = position(window.history.state);
if (from && to && from.key === to.key && from.index !== to.index) {
restoring.current = true;
window.history.go(from.index - to.index);
} else {
// Host-created entries lack our position. Restore the visible URL without
// inventing a direction or navigating outside the application's document.
window.history.replaceState(previous.state, "", previous.href);
}
return;
}
current.current = { route: next, href: window.location.href, state: window.history.state };
setRoute(next);
};
const onBeforeUnload = (event: BeforeUnloadEvent) => {
if (!policy.current.hasUnsavedWork()) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("popstate", onPopState);
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
window.removeEventListener("popstate", onPopState);
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, []);
function navigate(next: AdministrationRoute, approved = false) {
if (restoring.current) return;
const previous = current.current;
if (next.surface === previous.route.surface && next.workspace === previous.route.workspace) return;
if (!approved && !policy.current.canLeave()) return;
const oldPosition = position(window.history.state) ?? { index: 0, key: crypto.randomUUID() };
const state = { ...window.history.state, thothNavigation: { ...oldPosition, index: oldPosition.index + 1 } };
window.history.pushState(state, "", hrefFor(next));
current.current = { route: next, href: window.location.href, state };
setRoute(next);
}
return { route, navigate };
}
const NavigationContext = createContext<((route: AdministrationRoute) => void) | undefined>(undefined);
export function AdministrationNavigationProvider({ navigate, children }: { navigate: (route: AdministrationRoute) => void; children: ReactNode }) {
return <NavigationContext.Provider value={navigate}>{children}</NavigationContext.Provider>;
}
export function AdministrationLink({ surface, workspace, children, ...props }: {
surface: AdministrationSurface; workspace?: string;
} & AnchorHTMLAttributes<HTMLAnchorElement>) {
const navigate = useContext(NavigationContext);
const route = { surface, workspace };
return <a {...props} href={hrefFor(route)} onClick={event => {
props.onClick?.(event);
if (!navigate || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault(); navigate(route);
}}>{children}</a>;
}
@@ -0,0 +1,119 @@
.thot-app-shell {
position: relative;
width: 100%;
min-width: 0;
min-height: 0;
height: var(--thoth-app-height, 100dvh);
overflow: hidden;
}
.thot-administration-page,
.thot-work-area-page {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: auto;
background: oklch(var(--background));
color: oklch(var(--foreground));
container: administration / inline-size;
}
.thot-administration-header {
display: flex;
flex: none;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1.25rem;
padding: 1.65rem 1.75rem 1.35rem;
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--muted) / 0.35);
}
.thot-administration-header__copy { min-width: 0; flex: 1 1 20rem; }
.thot-administration-header h1 {
margin: 0.35rem 0 0;
font-family: var(--font-heading);
font-size: 1.875rem;
line-height: 1.15;
font-weight: 600;
letter-spacing: -0.025em;
overflow-wrap: anywhere;
}
.thot-administration-eyebrow {
margin: 0;
font: 600 0.6875rem/1.4 var(--font-mono);
color: oklch(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.065em;
}
.thot-administration-description {
max-width: 68ch;
margin: 0.6rem 0 0;
font-size: 0.875rem;
line-height: 1.6;
color: oklch(var(--muted-foreground));
}
.thot-administration-header__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; min-width: 0; max-width: 100%; }
.thot-administration-header__actions > label { min-width: 0; width: 15rem; max-width: 100%; }
.thot-administration-page :where(input, select, textarea, fieldset) { min-width: 0; max-width: 100%; }
.thot-administration-page :where(pre, code, dd) { overflow-wrap: anywhere; }
.thot-administration-page :where(p, li) { overflow-wrap: anywhere; }
.thot-administration-toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; padding: 1.25rem 1.75rem; }
.thot-administration-toolbar > label { min-width: 0; flex: 1 1 10rem; }
.thot-administration-split {
display: grid;
flex: none;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.4fr);
min-height: 18rem;
margin: 1.25rem 1.75rem;
border: 1px solid oklch(var(--border));
background: oklch(var(--card));
}
.thot-administration-index { min-width: 0; background: oklch(var(--muted) / 0.45); border-inline-end: 1px solid oklch(var(--border)); }
.thot-administration-detail { min-width: 0; padding: 1.35rem; }
.thot-administration-index :where(button[aria-current]) { background: oklch(var(--nav-active)); color: oklch(var(--nav-active-foreground)); }
.thot-administration-index button { overflow-wrap: anywhere; }
.thot-administration-section-label { padding: 1rem 1.25rem 0; font: 600 0.6875rem/1.4 var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: oklch(var(--muted-foreground)); }
.thot-administration-link { color: oklch(var(--primary)); text-decoration: underline; text-underline-offset: 0.2em; font-size: 0.875rem; }
.thot-administration-link:focus-visible { outline: 2px solid oklch(var(--ring)); outline-offset: 3px; }
.thot-administration-dependency { padding: 1rem 1.25rem; border-block: 1px solid oklch(var(--border)); background: oklch(var(--muted) / 0.35); font-size: 0.875rem; }
.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-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-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-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-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; }
}
@container administration (max-width: 30rem) {
.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; }
.thot-administration-split { margin: 0.75rem; }
}
@@ -0,0 +1,15 @@
import type { ReactNode } from "react";
import "./AdministrationPage.css";
export function AdministrationHeader({ title, description, actions, eyebrow = "Administration / Workbench" }: {
title: ReactNode; description?: ReactNode; actions?: ReactNode; eyebrow?: ReactNode;
}) {
return <header className="thot-administration-header">
<div className="thot-administration-header__copy">
<p className="thot-administration-eyebrow">{eyebrow}</p>
<h1>{title}</h1>
{description && <p className="thot-administration-description">{description}</p>}
</div>
{actions && <div className="thot-administration-header__actions">{actions}</div>}
</header>;
}
@@ -0,0 +1,30 @@
import { useQuery } from "@tanstack/react-query";
import { getWorkspacePreprocessingStatus } from "../../api/workspace-preprocessing";
import { WorkspacePreprocessingControl } from "../WorkspacePreprocessingControl";
import { AdministrationLink } from "./AdministrationNavigation";
export function WorkspacePreparation({ workspaceId, canManage, databaseSummary = false }: {
workspaceId: string; canManage: boolean; databaseSummary?: 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>
<p className="mt-2 text-sm text-muted-foreground">Preprocessing prepares this workspace's schema, value lookup and Evidence indexes from the Database catalog. Memory remains separate.</p>
<AdministrationLink surface="database-management" workspace={workspaceId} className="thot-administration-link">Open Database configuration and schema</AdministrationLink>
{query.data && <dl className="mt-3 flex flex-wrap gap-5 text-sm">
<div><dt className="text-muted-foreground">Catalog revision</dt><dd className="font-mono">{query.data.metadataRevision ?? "Unavailable"}</dd></div>
<div><dt className="text-muted-foreground">Prepared revision</dt><dd className="font-mono">{query.data.preprocessedMetadataRevision ?? "Not prepared"}</dd></div>
</dl>}
<WorkspacePreprocessingControl key={workspaceId} workspaceId={workspaceId} status={query.data} loading={query.isLoading} unavailable={query.isError} canManage={canManage} presentation="page" />
{query.isError && <button className="thot-administration-link" onClick={() => void query.refetch()}>Retry readiness</button>}
</section>;
}
@@ -2,6 +2,7 @@ import { forwardRef, useId, useState, type ReactNode } from "react"
import { ArrowLeft, ChevronRight } from "lucide-react"
import { Button } from "../../components/ui/button"
import { AdministrationHeader } from "../administration/AdministrationPage"
import {
WorkAreaPanel,
WorkAreaPanelHostProvider,
@@ -117,17 +118,13 @@ export function FleetLedgerHeader({
className,
}: FleetLedgerHeaderProps) {
return (
<header className={classes("thot-fleet-ledger__header", className)}>
<div className="thot-fleet-ledger__heading-copy">
<h1 className="thot-fleet-ledger__title">{title}</h1>
</div>
{status ? <div className="thot-fleet-ledger__header-status">{status}</div> : null}
{actions ? (
<div className="thot-fleet-ledger__header-actions" role="group" aria-label={actionsLabel}>
<div className={className}>
<AdministrationHeader title={title} description="Configure database connections, synchronize schema and curate the catalog." />
<div className="thot-fleet-ledger__header-status">{status}</div>
{actions && <div className="thot-administration-toolbar" role="group" aria-label={actionsLabel}>
{actions}
</div>
) : null}
</header>
</div>}
</div>
)
}
@@ -1,4 +1,5 @@
import { useId } from "react";
import { useWorkingContextScope } from "../../workspaces/WorkingContext";
import type { MetadataGenerationModels } from "../../api/catalog-databases";
export const NO_METADATA_GENERATION_LLM_MODEL_MESSAGE =
@@ -24,16 +25,19 @@ export function MetadataGenerationModelSelector({
variant = "default",
}: Props) {
const helpId = useId();
const workingContext = useWorkingContextScope();
if (workingContext) return null;
const models = data?.models ?? [];
const configuredDefault = data?.default ?? "";
const hasUsableDefault = configuredDefault !== ""
&& models.some((model) => model.id === configuredDefault);
const noUsableModel = !isLoading && !isError && Boolean(data) && !hasUsableDefault;
const staleSelection = Boolean(selectedModel) && !models.some((model) => model.id === selectedModel);
const unavailableMessage = !isLoading && isError
? "Metadata-generation LLM model choices are unavailable."
? "Global LLM model choices are unavailable."
: noUsableModel
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: null;
: staleSelection ? "The global LLM model is unavailable. Choose a configured model; no fallback is applied." : null;
const compact = variant === "compact";
return (
@@ -45,15 +49,16 @@ export function MetadataGenerationModelSelector({
? "flex min-w-0 items-center gap-2 text-xs font-medium text-foreground"
: "grid gap-1.5 text-xs font-medium text-foreground"}
>
<span className={compact ? "whitespace-nowrap" : undefined}>Metadata-generation LLM model</span>
<span className={compact ? "whitespace-nowrap" : undefined}>Global LLM model</span>
<select
className={`${compact ? "h-8 min-w-52" : "h-9 w-full"} rounded-md border border-input bg-card px-3 text-sm outline-none transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15 disabled:bg-muted disabled:text-muted-foreground`}
aria-label="Metadata-generation LLM model"
aria-label="Global LLM model"
aria-describedby={unavailableMessage ? helpId : undefined}
value={selectedModel}
disabled={!canManage || isLoading || isError || !hasUsableDefault}
onChange={(event) => onSelectedModelChange(event.target.value)}
>
{staleSelection && <option value={selectedModel}>{selectedModel} (unavailable)</option>}
{isLoading ? <option value="">Loading models…</option> : null}
{!isLoading && isError ? <option value="">Model choices unavailable</option> : null}
{noUsableModel ? (
@@ -64,6 +69,7 @@ export function MetadataGenerationModelSelector({
))}
</select>
</label>
<span className="text-xs text-muted-foreground">Shared with Core and all workspaces.</span>
{unavailableMessage ? (
<p id={helpId} className="max-w-72 text-xs font-normal leading-4 text-muted-foreground">
{unavailableMessage}
+3 -2
View File
@@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { act } from "react";
import { server } from "../test/msw";
import { workspaceSummaryFixture } from "../test/workspace-fixtures";
import { FakeEventSource } from "../test/fakeEventSource";
import { App } from "../App";
import { useSessionStore } from "../store/sessionStore";
@@ -23,8 +24,8 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async
http.get("/api/settings", () => HttpResponse.json({
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
})),
http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("default")])),
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }] })),
http.post("/api/sessions/s1/response", async ({ request }) => {
responded = await request.json();
return new HttpResponse(null, { status: 204 });
+4 -1
View File
@@ -38,6 +38,9 @@ if (!window.matchMedia) {
}
beforeAll(() => server.listen());
beforeEach(() => workspacePreferences.reset());
beforeEach(() => {
workspacePreferences.reset();
window.history.replaceState(null, "", "/");
});
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
@@ -0,0 +1,53 @@
import { createContext, useContext, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { listWorkspaces } from "../api/workspaces";
import { listModels } from "../api/models";
import { getSettings } from "../api/settings";
import { workspacePreferences } from "./preferences";
import { workspacePolicyGate } from "./drafts";
/** One validated operational context. Never substitute another model for an unavailable choice. */
export function useWorkingContext() {
const [preferences, setPreferences] = useState(() => workspacePreferences.load());
useEffect(() => {
const unsubscribe = workspacePreferences.subscribe(setPreferences);
setPreferences(workspacePreferences.load());
return unsubscribe;
}, []);
const settings = useQuery({ queryKey: ["settings"], queryFn: getSettings });
const workspacesQuery = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces });
const modelsQuery = useQuery({ queryKey: ["models"], queryFn: listModels });
const workspaces = (workspacesQuery.data ?? []).filter(workspace => workspace.revision);
const models = modelsQuery.data?.models ?? [];
const defaultWorkspace = settings.data?.workspace;
useEffect(() => {
if (!settings.data || !workspacesQuery.isSuccess) return;
workspacePreferences.migrate({ workspaceId: defaultWorkspace, provider: settings.data.provider,
model: settings.data.model, thinking: settings.data.thinking });
}, [settings.data, workspacesQuery.isSuccess, defaultWorkspace]);
const workspaceId = preferences.workspaceId ?? defaultWorkspace ?? "";
const modelId = preferences.provider && preferences.model ? `${preferences.provider}/${preferences.model}`
: settings.data?.provider && settings.data.model ? `${settings.data.provider}/${settings.data.model}` : "";
const workspace = workspaces.find(item => item.id === workspaceId);
const model = models.find(item => `${item.provider}/${item.id}` === modelId);
const loading = settings.isPending || workspacesQuery.isPending || modelsQuery.isPending;
const error = settings.isError || workspacesQuery.isError || modelsQuery.isError;
// A failed background refresh must not unmount an editor and destroy its unsaved draft.
// Initial failures still fail closed: no validated catalog/settings means no ready context.
const ready = !loading && !!settings.data && !!workspace && !!model;
useEffect(() => {
if (!workspaceId) workspacePolicyGate.clear();
else if (workspacesQuery.isPending) workspacePolicyGate.beginSummary(workspaceId);
else if (!workspace) workspacePolicyGate.rejectSummary(workspaceId);
else { workspacePolicyGate.select(workspaceId); workspacePolicyGate.resolve(workspaceId); }
}, [workspaceId, workspace, workspacesQuery.isPending, workspacesQuery.isError]);
return { preferences, workspaces, models, workspaceId, modelId, workspace, model, loading, error, ready,
origins: workspacePreferences.origins(),
retry: () => { void settings.refetch(); void workspacesQuery.refetch(); void modelsQuery.refetch(); } };
}
export interface WorkingContextScope { workspaceId: string; ready: boolean; locked: boolean; administrationBusy?: boolean }
export const WorkingContextContext = createContext<WorkingContextScope | null>(null);
export const WorkingContextProvider = WorkingContextContext.Provider;
// The optional scope keeps independently rendered management pages usable in their existing tests/tools.
export function useWorkingContextScope() { return useContext(WorkingContextContext); }
@@ -1,11 +1,40 @@
import { beforeEach, expect, test, vi } from "vitest";
import { workspacePreferences } from "./preferences";
import { clearAuthState, setAuthState } from "../auth/authState";
const userA = {
issuer: "local", subject: "preference-user-a", roles: ["user"] as const,
permissions: ["session.use"], isAdmin: false, csrfToken: "a".repeat(43), session: null,
};
beforeEach(() => {
localStorage.clear();
clearAuthState();
workspacePreferences.reset();
});
test("remembers only explicit global model choices per user, independently of workspace", () => {
setAuthState(userA);
workspacePreferences.migrate({ workspaceId: "one", provider: "zai", model: "glm-5.3" });
expect(localStorage.length).toBe(0);
workspacePreferences.selectModel("local/qwen");
workspacePreferences.save({ ...workspacePreferences.load(), workspaceId: "two" });
workspacePreferences.reset();
expect(workspacePreferences.load()).toEqual({ provider: "local", model: "qwen" });
setAuthState({ ...userA, subject: "preference-user-b" });
expect(workspacePreferences.load()).toEqual({});
workspacePreferences.migrate({ provider: "zai", model: "glm-5.3" });
setAuthState(userA);
expect(workspacePreferences.load()).toEqual({ provider: "local", model: "qwen" });
});
test("Admin-first initialization can later receive workspace defaults without replacing its model", () => {
workspacePreferences.migrate({ provider: "local", model: "qwen" });
workspacePreferences.selectModel("zai/glm-5.3");
expect(workspacePreferences.migrate({ workspaceId: "psd", provider: "local", model: "qwen", thinking: "medium" }))
.toEqual({ workspaceId: "psd", provider: "zai", model: "glm-5.3", thinking: "medium" });
});
test("keeps selections only in current application memory", () => {
localStorage.setItem("unrelated", "keep-me");
workspacePreferences.save({
@@ -50,3 +79,34 @@ test("notifies shell consumers when the selected workspace changes", () => {
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenLastCalledWith({ workspaceId: "catalog-two" });
});
test("remembers workspace and model independently, ahead of defaults, per principal", () => {
setAuthState(userA);
workspacePreferences.migrate({ workspaceId: "default", provider: "zai", model: "glm-5.3" });
expect(workspacePreferences.origins()).toEqual({ workspace: "Installation default", model: "Installation default" });
workspacePreferences.selectWorkspace("research");
workspacePreferences.selectModel("deepseek/deepseek-v4-pro");
workspacePreferences.selectWorkspace("clinical");
workspacePreferences.reset();
expect(workspacePreferences.migrate({ workspaceId: "default", provider: "zai", model: "glm-5.3" }))
.toMatchObject({ workspaceId: "clinical", provider: "deepseek", model: "deepseek-v4-pro" });
expect(workspacePreferences.origins()).toEqual({ workspace: "Last choice", model: "Last choice" });
setAuthState({ ...userA, subject: "another-user" });
expect(workspacePreferences.load()).toEqual({});
});
test("isolates remembered context between application mounts", () => {
setAuthState(userA);
const original = location.href;
try {
history.replaceState(null, "", "/portal-one");
workspacePreferences.selectWorkspace("research");
workspacePreferences.selectModel("local/qwen");
history.replaceState(null, "", "/portal-two");
workspacePreferences.reset();
expect(workspacePreferences.load()).toEqual({});
history.replaceState(null, "", "/portal-one");
workspacePreferences.reset();
expect(workspacePreferences.load()).toEqual({ workspaceId: "research", provider: "local", model: "qwen" });
} finally { history.replaceState(null, "", original); }
});
+69 -6
View File
@@ -8,19 +8,52 @@ export interface WorkspacePreference {
let current: WorkspacePreference | undefined;
const listeners = new Set<(value: WorkspacePreference) => void>();
function modelStorageKey(): string | undefined {
const user = getAuthState();
// localStorage is already origin-scoped. Include application mount and principal, not
// workspace: selecting another workspace must never select another LLM.
return user ? `thothii:interaction-model:v1:${location.pathname}:${JSON.stringify([user.issuer, user.subject])}` : undefined;
}
function rememberedModel(): Partial<WorkspacePreference> {
try {
const key = modelStorageKey();
const id: unknown = key ? JSON.parse(localStorage.getItem(key) ?? "null") : null;
if (typeof id !== "string" || !/^[a-z][a-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(id)) return {};
const slash = id.indexOf("/");
return { provider: id.slice(0, slash), model: id.slice(slash + 1) };
} catch { return {}; }
}
function workspaceStorageKey(): string | undefined {
return modelStorageKey()?.replace("interaction-model:v1", "workspace:v1");
}
function rememberedWorkspace(): Partial<WorkspacePreference> {
try {
const key = workspaceStorageKey();
const id: unknown = key ? JSON.parse(localStorage.getItem(key) ?? "null") : null;
return typeof id === "string" && id.length > 0 && id.length <= 256 ? { workspaceId: id } : {};
} catch { return {}; }
}
subscribeAuthState(() => {
current = undefined;
notify();
});
function snapshot(value: WorkspacePreference | undefined): WorkspacePreference {
return value === undefined ? {} : { ...value };
}
function notify(): void {
const value = snapshot(current);
const value = { ...rememberedWorkspace(), ...rememberedModel(), ...snapshot(current) };
for (const listener of listeners) listener(value);
}
/** Ephemeral workspace choices for the current application process. */
/** Shared context: explicit workspace/model choices are independently remembered per user/mount. */
export const workspacePreferences = {
load(): WorkspacePreference {
return snapshot(current);
return { ...rememberedWorkspace(), ...rememberedModel(), ...snapshot(current) };
},
save(value: WorkspacePreference): WorkspacePreference {
@@ -29,10 +62,39 @@ export const workspacePreferences = {
return snapshot(current);
},
/** Seed current memory from backend installation settings only before the first local choice. */
/** The only explicit operational LLM choice, shared by Core and Administration. */
selectModel(id: string): WorkspacePreference {
const slash = id.indexOf("/");
if (slash <= 0 || slash === id.length - 1) throw new Error("Invalid model identity");
const value = { ...this.load(), provider: id.slice(0, slash), model: id.slice(slash + 1) };
try {
const key = modelStorageKey();
if (key) localStorage.setItem(key, JSON.stringify(id));
} catch { /* Keep this visit usable when browser storage is disabled. */ }
return this.save(value);
},
selectWorkspace(workspaceId: string): WorkspacePreference {
try {
const key = workspaceStorageKey();
if (key) localStorage.setItem(key, JSON.stringify(workspaceId));
} catch { /* Preserve this visit's selection even without browser storage. */ }
return this.save({ ...this.load(), workspaceId });
},
origins() {
return {
workspace: rememberedWorkspace().workspaceId ? "Last choice" : "Installation default",
model: rememberedModel().model ? "Last choice" : "Installation default",
};
},
/** Fill missing context fields without overwriting explicit choices, including Admin-first entry. */
migrate(settings: WorkspacePreference): WorkspacePreference {
if (current === undefined) {
current = { ...settings };
const selected = this.load();
const next = { ...settings, ...selected };
if (JSON.stringify(current) !== JSON.stringify(next)) {
current = next;
notify();
}
return snapshot(current);
@@ -49,3 +111,4 @@ export const workspacePreferences = {
notify();
},
};
import { getAuthState, subscribeAuthState } from "../auth/authState";