feat: establish unified administration and model context baseline
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 }) });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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])),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user