Add full shell, replaceable Omics adapter and bilingual interaction
Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { App } from "../../App";
|
||||
import { server } from "../../test/msw";
|
||||
import { workspaceSummaryFixture } from "../../test/workspace-fixtures";
|
||||
import { clearAuthState, getAuthState } from "../../auth/authState";
|
||||
import { queryClient } from "../../app/queryClient";
|
||||
import { setLocale } from "../../i18n";
|
||||
import { rememberSession, readRememberedSession } from "./rememberedSession";
|
||||
import { FakeEventSource } from "../../test/fakeEventSource";
|
||||
import { workspacePreferences } from "../../workspaces/preferences";
|
||||
|
||||
const user = { issuer: "local", subject: "analyst", displayName: "Analyst", roles: ["user"], permissions: ["session.use"], isAdmin: false, csrfToken: "c".repeat(43), session: null };
|
||||
let fullscreen: Element | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
clearAuthState();
|
||||
queryClient.clear();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setLocale("en");
|
||||
workspacePreferences.selectWorkspace("default");
|
||||
workspacePreferences.selectModel("test/test");
|
||||
window.__THOTHII_CONFIG__ = { shell: { mode: "full", defaultLocale: "en" } };
|
||||
fullscreen = null;
|
||||
FakeEventSource.instances = [];
|
||||
vi.stubGlobal("EventSource", FakeEventSource);
|
||||
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => fullscreen });
|
||||
Object.defineProperty(document.documentElement, "requestFullscreen", { configurable: true, value: vi.fn(async () => {
|
||||
fullscreen = document.documentElement;
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
}) });
|
||||
Object.defineProperty(document, "exitFullscreen", { configurable: true, value: vi.fn(async () => {
|
||||
fullscreen = null;
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
}) });
|
||||
server.use(
|
||||
http.get("/api/auth/config", () => HttpResponse.json({ mode: "local", localLogin: true, oidcLogin: false })),
|
||||
http.get("/api/me", () => HttpResponse.json(user)),
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
|
||||
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.get("/api/sessions", () => HttpResponse.json([])),
|
||||
http.get("/api/workspaces/default/preprocessing", () => HttpResponse.json({ state: "ready" })),
|
||||
http.get("/api/workspace-registry/status", () => HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false })),
|
||||
http.post("/api/runtime/prewarm", () => HttpResponse.json({})),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearAuthState();
|
||||
delete window.__THOTHII_CONFIG__;
|
||||
document.querySelector(".omics-language-select")?.remove();
|
||||
document.documentElement.removeAttribute("data-bs-theme");
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
setLocale("en");
|
||||
});
|
||||
|
||||
test("full controls change locale/theme and follow fullscreen entry, exit, Esc, and rejection", async () => {
|
||||
render(<App />);
|
||||
const header = await screen.findByRole("banner", { name: "Application header" });
|
||||
expect(within(header).getByRole("button", { name: "Analyst" })).toBeInTheDocument();
|
||||
expect(within(header).queryByRole("button", { name: /admin|settings/i })).not.toBeInTheDocument();
|
||||
await userEvent.selectOptions(within(header).getByRole("combobox", { name: "Interface language" }), "it");
|
||||
expect(await screen.findByRole("button", { name: "Nuova sessione" })).toBeInTheDocument();
|
||||
await userEvent.selectOptions(within(header).getByRole("combobox", { name: "Lingua dell’interfaccia" }), "en");
|
||||
await userEvent.click(within(header).getByRole("button", { name: "Use dark theme" }));
|
||||
expect(document.documentElement).toHaveAttribute("data-bs-theme", "dark");
|
||||
await userEvent.click(within(header).getByRole("button", { name: "Use light theme" }));
|
||||
expect(document.documentElement).toHaveAttribute("data-bs-theme", "light");
|
||||
await userEvent.click(within(header).getByRole("button", { name: "Enter fullscreen" }));
|
||||
expect(within(header).getByRole("button", { name: "Exit fullscreen" })).toHaveAttribute("aria-pressed", "true");
|
||||
await userEvent.click(within(header).getByRole("button", { name: "Exit fullscreen" }));
|
||||
expect(document.exitFullscreen).toHaveBeenCalledOnce();
|
||||
await userEvent.click(within(header).getByRole("button", { name: "Enter fullscreen" }));
|
||||
act(() => { fullscreen = null; document.dispatchEvent(new Event("fullscreenchange")); });
|
||||
expect(within(header).getByRole("button", { name: "Enter fullscreen" })).toHaveAttribute("aria-pressed", "false");
|
||||
vi.mocked(document.documentElement.requestFullscreen).mockRejectedValueOnce(new Error("Denied"));
|
||||
await userEvent.click(within(header).getByRole("button", { name: "Enter fullscreen" }));
|
||||
expect(await within(header).findByRole("alert")).toHaveTextContent("Fullscreen could not be changed");
|
||||
expect(within(header).getByRole("button", { name: "Enter fullscreen" })).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
test.each(["local", "oidc"])("full %s logs out from the username menu and clears remembered selection", async mode => {
|
||||
server.use(http.get("/api/auth/config", () => HttpResponse.json({ mode, localLogin: mode === "local", oidcLogin: mode === "oidc" })));
|
||||
const logout = vi.fn();
|
||||
server.use(http.post("/api/auth/logout", () => { logout(); return HttpResponse.json({}); }));
|
||||
render(<App />);
|
||||
const header = await screen.findByRole("banner", { name: "Application header" });
|
||||
rememberSession(user, "s1");
|
||||
await userEvent.click(within(header).getByRole("button", { name: "Analyst" }));
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: "Log out" }));
|
||||
await waitFor(() => expect(logout).toHaveBeenCalledOnce());
|
||||
await waitFor(() => expect(getAuthState()).toBeNull());
|
||||
expect(readRememberedSession(user)).toBeNull();
|
||||
});
|
||||
|
||||
test("the username-menu logout preserves an unsaved administration draft", async () => {
|
||||
const admin = { ...user, roles: ["admin"], isAdmin: true, permissions: ["session.use", "memory.manage"] };
|
||||
const logout = vi.fn();
|
||||
server.use(
|
||||
http.get("/api/me", () => HttpResponse.json(admin)),
|
||||
http.get("/api/workspaces/default/memory", () => HttpResponse.json({ items: [], total: 0, page: 1, page_size: 25 })),
|
||||
http.get("/api/workspaces/default/memory/pending", () => HttpResponse.json([])),
|
||||
http.post("/api/auth/logout", () => { logout(); return HttpResponse.json({}); }),
|
||||
);
|
||||
window.history.replaceState(null, "", "/?thoth_route=administration/memory");
|
||||
render(<App />);
|
||||
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"), "Keep this draft");
|
||||
const header = screen.getByRole("banner", { name: "Application header" });
|
||||
await userEvent.click(within(header).getByRole("button", { name: "Analyst" }));
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: "Log out" }));
|
||||
expect(logout).not.toHaveBeenCalled();
|
||||
expect(getAuthState()?.subject).toBe("analyst");
|
||||
expect(within(page).getByLabelText("Title")).toHaveValue("Keep this draft");
|
||||
});
|
||||
|
||||
test("legacy embedded consumes the portal state and supplies no duplicate controls", async () => {
|
||||
delete window.__THOTHII_CONFIG__;
|
||||
document.documentElement.lang = "en";
|
||||
document.documentElement.setAttribute("data-bs-theme", "dark");
|
||||
const select = document.createElement("select");
|
||||
select.className = "omics-language-select";
|
||||
select.dataset.lang = "it";
|
||||
document.body.append(select);
|
||||
render(<App />);
|
||||
expect(await screen.findByRole("button", { name: "Nuova sessione" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("banner", { name: /application|intestazione/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /fullscreen|tema|Log out/i })).not.toBeInTheDocument();
|
||||
act(() => { document.documentElement.setAttribute("data-bs-theme", "light"); });
|
||||
await waitFor(() => expect(document.querySelector(".thot-host")).toHaveAttribute("data-theme", "light"));
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
});
|
||||
|
||||
test("missing embedded portal data shows an integration error with no full fallback", async () => {
|
||||
delete window.__THOTHII_CONFIG__;
|
||||
render(<App />);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Portal preferences are unavailable");
|
||||
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Password")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("reload restores only an authorized session document panel without resuming or opening a stream", async () => {
|
||||
const resume = vi.fn();
|
||||
const session = { id: "saved", name: "Saved analysis", question: "Domanda italiana", status: "open", active: true, archived: false, author: "analyst", group: "", workspace: "default" };
|
||||
rememberSession(user, session.id);
|
||||
server.use(
|
||||
http.get("/api/sessions", () => HttpResponse.json([session])),
|
||||
http.get("/api/sessions/saved/documents", () => HttpResponse.json([])),
|
||||
http.post("/api/sessions/saved/resume", () => { resume(); return HttpResponse.json({}); }),
|
||||
);
|
||||
render(<App />);
|
||||
expect(await screen.findByRole("button", { name: "Resume" })).toBeInTheDocument();
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
expect(FakeEventSource.instances).toHaveLength(0);
|
||||
expect(readRememberedSession(user)).toBe("saved");
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "New session" })).toBeEnabled());
|
||||
await userEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||
expect(readRememberedSession(user)).toBeNull();
|
||||
});
|
||||
|
||||
test("reload ignores a session absent from the authorized list and isolates principal and mount", async () => {
|
||||
rememberSession(user, "removed");
|
||||
expect(readRememberedSession({ ...user, subject: "another" })).toBeNull();
|
||||
window.history.replaceState(null, "", "/another-mount/");
|
||||
expect(readRememberedSession(user)).toBeNull();
|
||||
window.history.replaceState(null, "", "/");
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: "New session" });
|
||||
await waitFor(() => expect(readRememberedSession(user)).toBeNull());
|
||||
expect(screen.queryByRole("button", { name: "Resume" })).not.toBeInTheDocument();
|
||||
expect(FakeEventSource.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("reload restores another user's selected session from the authorized All sessions list", async () => {
|
||||
const admin = { ...user, roles: ["admin"], isAdmin: true, permissions: ["session.use", "session.read_all"] };
|
||||
const session = { id: "foreign", name: "Another analyst's work", question: "Original question", status: "open", active: false, archived: false, author: "another", group: "", workspace: "default" };
|
||||
const requestedScopes: string[] = [];
|
||||
const resume = vi.fn();
|
||||
server.use(
|
||||
http.get("/api/me", () => HttpResponse.json(admin)),
|
||||
http.get("/api/sessions", ({ request }) => {
|
||||
const scope = new URL(request.url).searchParams.get("scope") ?? "mine";
|
||||
requestedScopes.push(scope);
|
||||
return HttpResponse.json(scope === "all" ? [session] : []);
|
||||
}),
|
||||
http.get("/api/sessions/foreign/documents", () => HttpResponse.json([])),
|
||||
http.post("/api/sessions/foreign/resume", () => { resume(); return HttpResponse.json({}); }),
|
||||
);
|
||||
const first = render(<App />);
|
||||
await userEvent.click(await screen.findByRole("tab", { name: "All sessions" }));
|
||||
await userEvent.click(await screen.findByText("Another analyst's work"));
|
||||
expect(await screen.findByRole("button", { name: "Resume" })).toBeInTheDocument();
|
||||
expect(readRememberedSession(admin)).toBe("foreign");
|
||||
first.unmount();
|
||||
requestedScopes.length = 0;
|
||||
|
||||
render(<App />);
|
||||
expect(await screen.findByRole("button", { name: "Resume" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "All sessions" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(requestedScopes).toEqual(["mine", "all"]);
|
||||
expect(readRememberedSession(admin)).toBe("foreign");
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
expect(FakeEventSource.instances).toHaveLength(0);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { Menu } from "@base-ui/react/menu";
|
||||
import { ChevronDown, Maximize, Minimize, Moon, Sun } from "lucide-react";
|
||||
import { Button, buttonVariants } from "../../components/ui/button";
|
||||
import { useAuthUser } from "../../auth/authState";
|
||||
import { availableLocales, useI18n } from "../../i18n";
|
||||
import { useShell } from "./ShellProvider";
|
||||
|
||||
export function FullHeader({ onLogout }: { onLogout?: () => Promise<void> }) {
|
||||
const shell = useShell();
|
||||
const { t } = useI18n();
|
||||
const user = useAuthUser();
|
||||
const [fullscreenPending, setFullscreenPending] = useState(false);
|
||||
const [logoutPending, setLogoutPending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
if (shell?.mode !== "full") return null;
|
||||
const fullscreenLabel = shell.fullscreen ? t("Exit fullscreen") : t("Enter fullscreen");
|
||||
|
||||
async function toggleFullscreen() {
|
||||
setFullscreenPending(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
if (document.fullscreenElement) await document.exitFullscreen();
|
||||
else await document.documentElement.requestFullscreen();
|
||||
} catch {
|
||||
setError("Fullscreen could not be changed. Try again or check your browser permissions.");
|
||||
} finally { setFullscreenPending(false); }
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
if (!onLogout || logoutPending) return;
|
||||
setLogoutPending(true);
|
||||
setError(undefined);
|
||||
try { await onLogout(); }
|
||||
catch { setError("Logout could not be completed. Please try again."); }
|
||||
finally { setLogoutPending(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="thot-full-header" aria-label={t("Application header")}>
|
||||
<span className="thot-full-header__brand">Thoth<span className="text-primary">II</span></span>
|
||||
<div className="thot-full-header__controls">
|
||||
<label className="sr-only" htmlFor="thot-ui-language">{t("Interface language")}</label>
|
||||
<select id="thot-ui-language" className="thot-shell-select" value={shell.locale} onChange={event => shell.changeLocale(event.target.value)}>
|
||||
{availableLocales.map(({ code, name }) => <option key={code} value={code} lang={code}>{name}</option>)}
|
||||
</select>
|
||||
<Button variant="ghost" size="icon" aria-label={shell.theme === "light" ? t("Use dark theme") : t("Use light theme")}
|
||||
title={shell.theme === "light" ? t("Use dark theme") : t("Use light theme")}
|
||||
onClick={() => shell.changeTheme(shell.theme === "light" ? "dark" : "light")}>
|
||||
{shell.theme === "light" ? <Moon aria-hidden="true" /> : <Sun aria-hidden="true" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" aria-label={fullscreenLabel} title={fullscreenLabel} aria-pressed={shell.fullscreen}
|
||||
disabled={fullscreenPending} onClick={() => { void toggleFullscreen(); }}>
|
||||
{shell.fullscreen ? <Minimize aria-hidden="true" /> : <Maximize aria-hidden="true" />}
|
||||
</Button>
|
||||
{user && (onLogout ? <Menu.Root>
|
||||
<Menu.Trigger className={`${buttonVariants({ variant: "ghost" })} thot-full-header__user`} disabled={logoutPending}>
|
||||
<span className="truncate">{user.displayName ?? user.subject}</span><ChevronDown aria-hidden="true" className="size-4" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal><Menu.Positioner side="bottom" align="end" sideOffset={4}>
|
||||
<Menu.Popup className="thot-user-menu">
|
||||
<Menu.Item className="thot-user-menu__item" onClick={() => { void logout(); }}>{t("Log out")}</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner></Menu.Portal>
|
||||
</Menu.Root> : <span className="thot-full-header__user truncate">{user.displayName ?? user.subject}</span>)}
|
||||
</div>
|
||||
{error && <p role="alert" className="thot-full-header__error">{t(error)}</p>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { OmicsPortalAdapter } from "./OmicsPortalAdapter";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
document.documentElement.removeAttribute("data-bs-theme");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function portal() {
|
||||
document.documentElement.lang = "en";
|
||||
document.documentElement.setAttribute("data-bs-theme", "dark");
|
||||
document.body.innerHTML = '<form action="/i18n/setlang/"><select class="omics-language-select" data-lang="it"><option value="it">Italiano</option></select></form>';
|
||||
}
|
||||
|
||||
test("publishes rendered language, observes theme and actual fullscreen, and cleans up repeated mounts", async () => {
|
||||
portal();
|
||||
let fullscreen: Element | null = null;
|
||||
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => fullscreen });
|
||||
const adapter = new OmicsPortalAdapter();
|
||||
for (let mount = 0; mount < 2; mount++) {
|
||||
document.documentElement.setAttribute("data-bs-theme", "dark");
|
||||
const state = vi.fn();
|
||||
const error = vi.fn();
|
||||
const unsubscribe = adapter.subscribe(state, error);
|
||||
expect(state).toHaveBeenLastCalledWith({ locale: "it", theme: "dark", fullscreen: false });
|
||||
document.documentElement.setAttribute("data-bs-theme", "light");
|
||||
await Promise.resolve();
|
||||
expect(state).toHaveBeenLastCalledWith({ locale: "it", theme: "light", fullscreen: false });
|
||||
fullscreen = document.documentElement;
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
expect(state).toHaveBeenLastCalledWith({ locale: "it", theme: "light", fullscreen: true });
|
||||
fullscreen = null;
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
expect(state).toHaveBeenLastCalledWith({ locale: "it", theme: "light", fullscreen: false });
|
||||
unsubscribe();
|
||||
state.mockClear();
|
||||
document.documentElement.setAttribute("data-bs-theme", "dark");
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
await Promise.resolve();
|
||||
expect(state).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test("missing Omics DOM reports an integration error instead of using fixed html language", () => {
|
||||
document.documentElement.lang = "it";
|
||||
const state = vi.fn();
|
||||
const error = vi.fn();
|
||||
const unsubscribe = new OmicsPortalAdapter().subscribe(state, error);
|
||||
expect(state).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringMatching(/portal preferences/i) }));
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test.each(["not_a_locale", "en--US", " "])("rejects malformed portal language %j", locale => {
|
||||
portal();
|
||||
document.querySelector<HTMLSelectElement>(".omics-language-select")!.dataset.lang = locale;
|
||||
const state = vi.fn();
|
||||
const error = vi.fn();
|
||||
const unsubscribe = new OmicsPortalAdapter().subscribe(state, error);
|
||||
expect(state).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringMatching(/portal preferences/i) }));
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("accepts a well-formed unsupported language for the shell's catalogue fallback", () => {
|
||||
portal();
|
||||
document.querySelector<HTMLSelectElement>(".omics-language-select")!.dataset.lang = "fr-ca";
|
||||
const state = vi.fn();
|
||||
const error = vi.fn();
|
||||
const unsubscribe = new OmicsPortalAdapter().subscribe(state, error);
|
||||
expect(state).toHaveBeenCalledWith(expect.objectContaining({ locale: "fr-CA" }));
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PortalAdapter, PortalSnapshot } from "./PortalAdapter";
|
||||
|
||||
/** Omics mounts ThothII in the same document; its language form navigates normally. */
|
||||
export class OmicsPortalAdapter implements PortalAdapter {
|
||||
constructor(private readonly document: Document = window.document) {}
|
||||
|
||||
subscribe(onState: (state: PortalSnapshot) => void, onError: (error: Error) => void): () => void {
|
||||
const doc = this.document;
|
||||
const publish = () => {
|
||||
const locale = doc.querySelector<HTMLSelectElement>("select.omics-language-select")?.dataset.lang;
|
||||
const theme = doc.documentElement.getAttribute("data-bs-theme");
|
||||
let canonicalLocale: string | undefined;
|
||||
try { canonicalLocale = locale ? Intl.getCanonicalLocales(locale)[0] : undefined; }
|
||||
catch { /* Malformed portal language is an integration error, not a fallback locale. */ }
|
||||
if (!canonicalLocale || (theme !== "light" && theme !== "dark")) {
|
||||
onError(new Error("Portal preferences are unavailable. Reopen ThothII from the portal."));
|
||||
return;
|
||||
}
|
||||
onState({ locale: canonicalLocale, theme, fullscreen: Boolean(doc.fullscreenElement) });
|
||||
};
|
||||
const observer = new MutationObserver(publish);
|
||||
observer.observe(doc.documentElement, { attributes: true, attributeFilter: ["data-bs-theme"] });
|
||||
doc.addEventListener("fullscreenchange", publish);
|
||||
// Subscribe first so no update can be lost between reading and listening.
|
||||
publish();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
doc.removeEventListener("fullscreenchange", publish);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface PortalSnapshot {
|
||||
locale: string;
|
||||
theme: "light" | "dark";
|
||||
fullscreen: boolean;
|
||||
}
|
||||
|
||||
export interface PortalAdapter {
|
||||
subscribe(onState: (state: PortalSnapshot) => void, onError: (error: Error) => void): () => void;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { SessionNotification } from "./SessionNotification";
|
||||
import { setLocale } from "../../i18n";
|
||||
|
||||
afterEach(() => { cleanup(); setLocale("en"); });
|
||||
|
||||
test("deterministic terminal notifications follow UI locale, not pinned session locale", () => {
|
||||
render(<SessionNotification text="Scegli «Salva e procedi» o «Rifiuta»." />);
|
||||
expect(screen.getByText("Choose Save and proceed or Reject.")).toBeInTheDocument();
|
||||
act(() => setLocale("it"));
|
||||
expect(screen.getByText("Scegli Salva e procedi o Rifiuta.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("localized command validation preserves the phase value", () => {
|
||||
render(<SessionNotification text="Target non valido (fase corrente 3)." />);
|
||||
expect(screen.getByText("Invalid target (current phase 3).")).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useI18n } from "../../i18n";
|
||||
|
||||
// The bridge's sanitized failures and the store's deterministic lifecycle text.
|
||||
// Other notification strings can come from the model and must remain verbatim,
|
||||
// even if their text happens to match a message elsewhere in the UI catalogue.
|
||||
const deterministicMessages = new Set([
|
||||
"Model request failed. Check provider connectivity, then Resume the session.",
|
||||
"Session startup failed. Check configuration and connectivity, then Resume the session.",
|
||||
"Session services are not ready. Check configuration and connectivity, then try again.",
|
||||
"Session could not be resumed. Check configuration and connectivity, then try again.",
|
||||
"Cannot start a session: the database is unreachable. Check the VPN connection and try again.",
|
||||
"Selected model is unavailable. Check Pi authentication and model settings, then try again.",
|
||||
"Session workspace configuration is unavailable. Check configuration and try again.",
|
||||
"Preparing retrieval context",
|
||||
"Starting model",
|
||||
"Session created",
|
||||
"The selected model is unavailable for the current subscription. Choose another model and start a new session.",
|
||||
"Agent started",
|
||||
"Agent finished",
|
||||
"Phase started",
|
||||
"Session exit",
|
||||
"Turn end",
|
||||
"System event",
|
||||
"Resuming session",
|
||||
"Review requested",
|
||||
]);
|
||||
|
||||
// Pi's terminal notifications already carry the pinned session language. Only
|
||||
// this finite application vocabulary is normalized back to source messages.
|
||||
const gateNotifications = new Map(Object.entries({
|
||||
"No reusable memories for this question; continuing to the next phase.": "Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.",
|
||||
"Esc does not close the gate: use Back / Exit / Other.": "Esc non chiude il gate: usa Torna indietro / Esci / Altro dalle opzioni.",
|
||||
"Answer through the gate widgets. To send free text to the model, start the line with '!'.": "Durante la sessione rispondi con i widget del gate. Per inviare testo libero al modello inizia la riga con '!'.",
|
||||
"Memory saved. Index update is incomplete; an administrator can retry it in Memory management.": "Memory salvata. L'aggiornamento dell'indice è incompleto; un amministratore può riprovarlo da Gestione Memory.",
|
||||
"Choose Save and proceed or Reject.": "Scegli «Salva e procedi» o «Rifiuta».",
|
||||
"Usage: /torna <session_id> [N]": "Uso: /torna <session_id> [N]",
|
||||
}).flatMap(([message, localized]) => [[message, message], [localized, message]]));
|
||||
|
||||
export function SessionNotification({ text }: { text: string }) {
|
||||
const { t } = useI18n();
|
||||
const gateMessage = gateNotifications.get(text);
|
||||
if (gateMessage) return <>{t(gateMessage)}</>;
|
||||
const invalidPhase = /^(?:Invalid target \(current phase |Target non valido \(fase corrente )(\d+)\)\.$/.exec(text);
|
||||
if (invalidPhase) return <>{t("Invalid target (current phase {phase}).", { phase: invalidPhase[1] })}</>;
|
||||
return <>{deterministicMessages.has(text) ? t(text) : text}</>;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createContext, useContext, useEffect, useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import { resolveShellConfig, type ShellConfig } from "../../api/runtime-config";
|
||||
import { resolveLocale, setLocale, useI18n } from "../../i18n";
|
||||
import { OmicsPortalAdapter } from "./OmicsPortalAdapter";
|
||||
import type { PortalSnapshot } from "./PortalAdapter";
|
||||
|
||||
export interface ShellController extends PortalSnapshot {
|
||||
mode: ShellConfig["mode"];
|
||||
changeLocale: (locale: string) => void;
|
||||
changeTheme: (theme: PortalSnapshot["theme"]) => void;
|
||||
}
|
||||
const ShellContext = createContext<ShellController | null>(null);
|
||||
|
||||
function preference(key: string): string | null {
|
||||
try { return localStorage.getItem(`thothii:shell:${key}`); } catch { return null; }
|
||||
}
|
||||
function savePreference(key: string, value: string) {
|
||||
try { localStorage.setItem(`thothii:shell:${key}`, value); } catch { /* Preferences are optional. */ }
|
||||
}
|
||||
|
||||
export function useShell(): ShellController {
|
||||
const controller = useContext(ShellContext);
|
||||
const { locale } = useI18n();
|
||||
// Isolated consumers have a usable presentation state without mounting a host.
|
||||
return controller ?? {
|
||||
mode: resolveShellConfig().mode,
|
||||
locale,
|
||||
theme: document.documentElement.getAttribute("data-bs-theme") === "dark" || document.documentElement.classList.contains("dark") ? "dark" as const : "light" as const,
|
||||
fullscreen: Boolean(document.fullscreenElement),
|
||||
changeLocale: () => {},
|
||||
changeTheme: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
export function ShellProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useI18n();
|
||||
const [configuration] = useState(() => {
|
||||
try { return { config: resolveShellConfig() }; }
|
||||
catch (error) { return { error: (error as Error).message }; }
|
||||
});
|
||||
const config = configuration.config;
|
||||
const [state, setState] = useState<PortalSnapshot>(() => ({
|
||||
locale: resolveLocale(config?.mode === "full" ? preference("locale") ?? config.defaultLocale : config?.defaultLocale ?? "en"),
|
||||
theme: config?.mode === "full" && preference("theme") === "dark" ? "dark" : "light",
|
||||
fullscreen: Boolean(document.fullscreenElement),
|
||||
}));
|
||||
const [ready, setReady] = useState(config?.mode === "full");
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.mode !== "full") return;
|
||||
document.body.classList.add("thot-full-document");
|
||||
return () => document.body.classList.remove("thot-full-document");
|
||||
}, [config]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!config) return;
|
||||
if (config.mode === "full") {
|
||||
const publish = () => setState(previous => ({ ...previous, fullscreen: Boolean(document.fullscreenElement) }));
|
||||
document.addEventListener("fullscreenchange", publish);
|
||||
publish();
|
||||
return () => document.removeEventListener("fullscreenchange", publish);
|
||||
}
|
||||
return new OmicsPortalAdapter().subscribe(snapshot => {
|
||||
setState({ ...snapshot, locale: resolveLocale(snapshot.locale) });
|
||||
setError(undefined);
|
||||
setReady(true);
|
||||
}, failure => { setError(failure.message); setReady(false); });
|
||||
}, [config]);
|
||||
|
||||
useLayoutEffect(() => { setLocale(state.locale); }, [state.locale]);
|
||||
useEffect(() => {
|
||||
if (config?.mode !== "full") return;
|
||||
const html = document.documentElement;
|
||||
const previousTheme = html.getAttribute("data-bs-theme");
|
||||
const previousLanguage = html.lang;
|
||||
html.setAttribute("data-bs-theme", state.theme);
|
||||
html.lang = state.locale;
|
||||
return () => {
|
||||
if (previousTheme === null) html.removeAttribute("data-bs-theme");
|
||||
else html.setAttribute("data-bs-theme", previousTheme);
|
||||
html.lang = previousLanguage;
|
||||
};
|
||||
}, [config, state.locale, state.theme]);
|
||||
|
||||
const controller: ShellController = {
|
||||
...state, mode: config?.mode ?? "embedded",
|
||||
changeLocale(locale) {
|
||||
if (config?.mode !== "full") return;
|
||||
const resolved = resolveLocale(locale);
|
||||
setLocale(resolved);
|
||||
savePreference("locale", resolved);
|
||||
setState(previous => ({ ...previous, locale: resolved }));
|
||||
},
|
||||
changeTheme(theme) {
|
||||
if (config?.mode !== "full") return;
|
||||
savePreference("theme", theme);
|
||||
setState(previous => ({ ...previous, theme }));
|
||||
},
|
||||
};
|
||||
return (
|
||||
<ShellContext.Provider value={controller}>
|
||||
<div className={`thot-host ${controller.mode === "full" ? "thot-host--full" : "thot-host--embedded"}${state.theme === "dark" ? " dark" : ""}`} lang={state.locale} data-theme={state.theme} data-fullscreen={state.fullscreen}>
|
||||
{configuration.error || error
|
||||
? <main className="thot-access-state" role="alert"><h1>{t("Shell unavailable")}</h1><p>{t(configuration.error ?? error!)}</p></main>
|
||||
: ready ? children : <p role="status">{t("Loading portal preferences…")}</p>}
|
||||
</div>
|
||||
</ShellContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AuthenticatedUser } from "../../api/types";
|
||||
|
||||
type Principal = Pick<AuthenticatedUser, "issuer" | "subject">;
|
||||
|
||||
function key(user: Principal) {
|
||||
return `thothii:selected-session:${JSON.stringify([window.location.pathname, user.issuer, user.subject])}`;
|
||||
}
|
||||
|
||||
export function readRememberedSession(user: Principal): string | null {
|
||||
try { return sessionStorage.getItem(key(user)); } catch { return null; }
|
||||
}
|
||||
|
||||
export function rememberSession(user: Principal, sessionId: string | null): void {
|
||||
try {
|
||||
if (sessionId) sessionStorage.setItem(key(user), sessionId);
|
||||
else sessionStorage.removeItem(key(user));
|
||||
} catch { /* Reload continuity is optional when browser storage is blocked. */ }
|
||||
}
|
||||
Reference in New Issue
Block a user