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:
Codex
2026-09-13 14:26:39 +02:00
parent 2d1b714ebe
commit d8a29bfbdd
207 changed files with 8570 additions and 2164 deletions
@@ -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);
});
+70
View File
@@ -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);
};
}
}
+9
View File
@@ -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}</>;
}
+110
View File
@@ -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. */ }
}