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
+4 -2
View File
@@ -98,8 +98,10 @@ frontend (React/SSE) → backend (Fastify) → pi --mode rpc → tht/harness →
- **`tht`'s `-c`/`--config` is a PER-COMMAND option** — it must follow the subcommand, never
precede it (`ThtRunner.buildArgv` enforces this; prepending caused live 500s).
- **`--json` output must be pristine** (only valid JSON on stdout) — used as a machine contract.
- **UI strings are English; document *content* stays the workspace language** (Italian for
`psd`) because it's the real data. Only chrome/labels are English.
- **Localization:** deterministic UI uses the EN/IT catalogs with English fallback;
model interaction uses the session manifest's immutable `interaction_language`.
Workspace content, SQL, and identifiers remain unchanged. For shell modes, portal
integration, or translations, read `docs/operations/shell-and-localization.md`.
- **Workspace schema v4** defines workspace identity and optional Evidence only. PostgreSQL Metadata
Catalog owns database identity, binding, schema, descriptions, sensitivity, and relationships;
embedding/model facts come from the installation catalog. The legacy `harness/workspaces/*.yaml` runtime snapshots still use
+29
View File
@@ -595,6 +595,35 @@ essere ripristinabile con refresh e cronologia e non contiene valori transitori
un portale host. Conserva la propria gerarchia funzionale, ma deve rispettare la geometria,
l'autenticazione e le regole responsive del portale host.
**Full Thoth Shell** — L'esperienza Thoth autonoma che possiede il proprio header e il proprio
layout di pagina. Non replica la navigazione amministrativa del portale host e non dipende dal suo
template visuale.
**Shell mode** — La scelta di installazione fra `embedded` e `full`. Determina chi possiede il
chrome globale, i comandi di identità e le integrazioni visuali, ma non cambia il workflow o la
persistenza delle sessioni.
**Fullscreen state** — Lo stato temporaneo in cui il documento applicativo occupa il fullscreen
del browser. È distinto da `Shell mode`: una Full Thoth Shell può essere aperta senza fullscreen;
il passaggio è attivato da un comando esplicito e può essere annullato con la stessa azione o con
il comando nativo del browser.
**Portal Shell Adapter** — Il confine sostituibile che traduce lo stato e i comandi del chrome di
un portale host nel modello semantico usato da Thoth. L'adapter non possiede autorizzazione,
sessioni di workflow o contenuti del modello.
**Host Shell State** — Il minimo stato visuale fornito dal portale host: locale UI, tema e stato
fullscreen. In una Embedded Thoth Shell è la fonte autorevole per queste preferenze;
non include identità, token o stato di autenticazione, che restano responsabilità dell'accesso.
**UI locale** — La lingua delle label, dei messaggi, dei tooltip, degli stati e delle istruzioni
non generate dal modello nell'interfaccia Thoth. È distinta dalla lingua dei contenuti di un
workspace.
**Interaction language** — La lingua in cui il modello presenta domande, spiegazioni e proposte
al revisore durante una sessione. Viene fissata alla creazione della sessione e rimane invariata
durante una ripresa, anche se la UI locale corrente cambia.
**Administrative Page Family** — L'insieme delle cinque Administration Page che condividono shell,
navigazione, tipografia e regole responsive, pur mantenendo contenuti e operazioni specifici:
Workspace, Evidence, Memory, Database e Pi.
+13 -1
View File
@@ -1,6 +1,6 @@
# ThothII — Project State
Last updated: 2026-09-12.
Last updated: 2026-09-13.
This file is the short operational snapshot. Stable commands and the architecture mental model
live in `AGENTS.md`; current design and runtime contracts live under `docs/architecture/`,
@@ -12,6 +12,18 @@ Authentik, internal catalog/embedding services, and the PSD workspace repository
`docs/operations/server-upgrade-gitea-workspace-v2.md`. Treat its operator gates and rollback
requirements as mandatory; do not replace the running server stack in place.
## Full/embedded shell and bilingual interface
Full/embedded shell, EN/IT UI, immutable session interaction language, dark theme,
fullscreen and full-mode logout are implemented. The local Mac descriptor explicitly
sets `shell.mode: full` and `shell.defaultLocale: en`; the native `tht` and local
core/frontend images were updated on 2026-09-13. Omics remains embedded and server
verified, through the replaceable presentation-only PortalAdapter. Its changes are
in local Omics commit `95154e1`; no production deployment or push was performed.
See `docs/operations/shell-and-localization.md` for integration and installation
instructions and `docs/reports/2026-09-13-full-shell-implementation.md` for tests,
independent reviews, local browser checks and rollback details.
## Current product shape
Gitea #28–#31 follow-up is implemented in this checkout: Workspace's four tabs,
+19 -6
View File
@@ -25,6 +25,7 @@ export interface SessionRuntime {
}
export interface RuntimeOptions {
interactionLanguage?: string | null;
provider?: string;
model?: string;
thinking?: string;
@@ -48,6 +49,7 @@ export class PiProcessManager {
private spawnFn: (
sessionId: string, author: string, provider: string | undefined, model: string | undefined,
principal?: PrincipalContext, runtimeConfigPath?: string,
interactionLanguage?: string | null,
) => ChildProcessWithoutNullStreams;
private loadAuthProviders: (agentDir: string) => ReadonlySet<string>;
private modelCatalog: RuntimeModelCatalog;
@@ -67,11 +69,11 @@ export class PiProcessManager {
this.loadAuthProviders = opts?.authProviders
?? ((agentDir) => loadPiAuthProviders({ agentDir }));
if (opts?.spawnFn) {
this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath) =>
this.spawnPi(opts.spawnFn!, sessionId, author, provider, model, principal, runtimeConfigPath);
this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath, language) =>
this.spawnPi(opts.spawnFn!, sessionId, author, provider, model, principal, runtimeConfigPath, language);
} else {
this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath) =>
this.spawnPi(nodeSpawn, sessionId, author, provider, model, principal, runtimeConfigPath);
this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath, language) =>
this.spawnPi(nodeSpawn, sessionId, author, provider, model, principal, runtimeConfigPath, language);
}
}
@@ -85,6 +87,7 @@ export class PiProcessManager {
private spawnPi(
spawnFn: SpawnFn, sessionId: string, author: string, provider: string | undefined,
model: string | undefined, principal?: PrincipalContext, runtimeConfigPath?: string,
interactionLanguage?: string | null,
): ChildProcessWithoutNullStreams {
// This is the final shared boundary for createFor(), spawnFor(), and resume(). Validate
// before auth-provider inspection, then make Pi consume the exact copied bytes rather than
@@ -110,6 +113,9 @@ export class PiProcessManager {
additions: { THT_SESSION: sessionId, THT_AUTHOR: author },
});
env.PI_CODING_AGENT_DIR = agent.agentDir;
// A launch hint only: the gate reads the authoritative manifest before each turn.
delete env.THT_INTERACTION_LANGUAGE;
if (interactionLanguage) env.THT_INTERACTION_LANGUAGE = interactionLanguage;
env.PI_CODING_AGENT_SESSION_DIR = agent.sessionDir;
clearPrincipalEnvironment(env);
if (principal) Object.assign(env, principalEnvironment(principal));
@@ -187,7 +193,9 @@ export class PiProcessManager {
const model = o.model ?? this.cfg.defaults.model;
let child: ChildProcessWithoutNullStreams;
try {
child = this.spawnFn(sessionId, author, provider, model, o.principal, o.runtimeConfig?.path);
child = this.spawnFn(
sessionId, author, provider, model, o.principal, o.runtimeConfig?.path, o.interactionLanguage,
);
} catch (error) {
o.runtimeConfig?.release();
throw error;
@@ -296,8 +304,13 @@ export class PiProcessManager {
}
async resume(sessionId: string, tht: ThtRunner): Promise<SessionRuntime> {
const manifest = await tht.sessionShow(sessionId) as { provider?: string; model?: string; thinking?: string } | null;
const manifest = await tht.sessionShow(sessionId) as {
provider?: string; model?: string; thinking?: string; interaction_language?: string | null;
} | null;
const language = manifest?.interaction_language
?? (await tht.ensureInteractionLanguage(sessionId)).interaction_language;
return this.spawnFor(sessionId, {
interactionLanguage: language,
provider: manifest?.provider,
model: manifest?.model,
thinking: manifest?.thinking,
+24 -1
View File
@@ -13,6 +13,7 @@ import type { MaintenanceBarrier } from "../runtime/maintenance-gate.js";
import { hasPermission, isPrincipalContext, requirePermission } from "../auth/authorization.js";
import { splitCanonicalModelId, type RuntimeModelCatalog } from "../models/runtime-model-catalog.js";
import type { CatalogRepository } from "../catalog/types.js";
import { interactionLanguage } from "../tht/interaction-language.js";
const BOOTSTRAP_FAILURE_MESSAGE =
"Session startup failed. Check configuration and connectivity, then Resume the session.";
@@ -381,8 +382,13 @@ export function sessionRoutes(
app.post("/sessions", async (req, reply) => {
const b = req.body as {
question: string; name?: string; workspace?: string; workspaceId?: string;
provider?: string; model?: string; thinking?: string;
provider?: string; model?: string; thinking?: string; interactionLanguage?: unknown;
};
const language = interactionLanguage(b?.interactionLanguage);
if (!language) return reply.code(400).send({
code: "invalid_interaction_language",
error: "interactionLanguage must be a well-formed BCP-47 language tag",
});
if ((b.provider === undefined) !== (b.model === undefined)
|| (b.provider !== undefined && (typeof b.provider !== "string" || typeof b.model !== "string" || !b.provider || !b.model))) {
return reply.code(400).send({ error: "provider and model must be supplied together" });
@@ -507,6 +513,7 @@ export function sessionRoutes(
({ id } = await runner.sessionNew({
question: b.question, name: b.name, workspaceConfigPath,
workspaceId, workspaceRevision, provider, model, thinking,
interactionLanguage: language,
}));
manifestPersisted = true;
if (revisionLease) {
@@ -520,6 +527,7 @@ export function sessionRoutes(
} catch { return storageFailure(reply); }
const options = {
provider, model, thinking,
interactionLanguage: language,
author: principal.displayName ?? principal.subject,
principal,
question: b.question,
@@ -664,6 +672,13 @@ export function sessionRoutes(
app.post("/sessions/:id/resume", async (req, reply) => {
const id = (req.params as any).id;
const principal = getPrincipal(req);
if (Object.hasOwn(req.body ?? {}, "interactionLanguage")
|| Object.hasOwn(req.body ?? {}, "interaction_language")) {
return reply.code(400).send({
code: "interaction_language_pinned",
error: "Resume uses the session's persisted interaction language; overrides are not accepted",
});
}
return withSessionLifecycle(id, async () => {
let settings: Settings;
let located: LocatedSession | undefined;
@@ -681,6 +696,7 @@ export function sessionRoutes(
const saved = manifest as {
provider?: string; model?: string; thinking?: string;
workspace_id?: string; workspace_revision?: string;
interaction_language?: string | null;
};
const requested = (req.body ?? {}) as { provider?: string; model?: string; thinking?: string };
if ((requested.provider === undefined) !== (requested.model === undefined)
@@ -719,6 +735,12 @@ export function sessionRoutes(
});
}
try { settings = await d.getSettings(principal); } catch { return storageFailure(reply); }
let language = saved.interaction_language;
if (language == null) {
try {
language = (await runner.ensureInteractionLanguage(id, workspaceConfigPath)).interaction_language;
} catch { return reply.code(503).send({ error: RESUME_FAILURE_MESSAGE }); }
}
// This check belongs inside the per-session lock: a preceding cold Resume may have
// installed a running runtime while this request was waiting.
const existing = d.mgr.get(id);
@@ -737,6 +759,7 @@ export function sessionRoutes(
});
const options = {
provider: selected.provider,
interactionLanguage: language,
model: selected.model,
thinking: saved?.thinking ?? settings.thinking,
author: principal.displayName ?? principal.subject,
+10
View File
@@ -0,0 +1,10 @@
/** Validate/canonicalize the tag; available translation catalogs belong to the UI. */
export function interactionLanguage(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
try {
const [canonical] = Intl.getCanonicalLocales(value);
return canonical;
} catch {
return undefined;
}
}
+9
View File
@@ -59,6 +59,7 @@ export interface SessionRow {
author: string | null;
workspace_id?: string | null;
workspace_revision?: string | null;
interaction_language?: string | null;
archived?: boolean;
}
@@ -482,6 +483,7 @@ export class ThtRunner {
async sessionNew(o: {
question: string;
interactionLanguage?: string;
provider?: string;
model?: string;
thinking?: string;
@@ -497,6 +499,7 @@ export class ThtRunner {
["--provider", o.provider],
["--model", o.model],
["--thinking", o.thinking],
["--interaction-language", o.interactionLanguage],
["--name", o.name],
["--workspace-id", o.workspaceId],
["--workspace-revision", o.workspaceRevision],
@@ -533,6 +536,12 @@ export class ThtRunner {
return this.json<unknown>(["session", "show", id, "--json"], workspace);
}
ensureInteractionLanguage(id: string, workspace?: string) {
return this.json<{ interaction_language: string }>(
["session", "ensure-interaction-language", id, "--json"], workspace,
);
}
sqlPreview(id: string, p: { limit?: number; offset?: number }, workspace?: string) {
// No positional FILE: the harness resolves sql_final.sql from the session
// via _session_sql_file(cfg, session_id), which respects the workspace path.
+2 -2
View File
@@ -36,7 +36,7 @@ test("loop F1: crea sessione → SSE riceve il widget → risponde → il modell
ollamaEnsure: async () => ({ ok: true }),
searchPack: async () => {},
sessionNew: async () => ({ id: "s1" }),
sessionShow: async (_id: string) => ({ id: "s1", provider: undefined, model: undefined, thinking: undefined }),
sessionShow: async (_id: string) => ({ id: "s1", interaction_language: "en", provider: undefined, model: undefined, thinking: undefined }),
sessionList: async () => [],
} as any,
spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any,
@@ -48,7 +48,7 @@ test("loop F1: crea sessione → SSE riceve il widget → risponde → il modell
const created = await fetch(`${base}/sessions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ workspace: "w", question: "q" }),
body: JSON.stringify({ workspace: "w", question: "q", interactionLanguage: "en" }),
});
expect(created.status).toBe(200);
+23
View File
@@ -190,6 +190,29 @@ test("Pi receives the leased workspace runtime config and releases it on direct
expect(release).toHaveBeenCalledOnce();
});
test("Pi language launch hint comes from the session options and never ambient environment", () => {
const previous = process.env.THT_INTERACTION_LANGUAGE;
process.env.THT_INTERACTION_LANGUAGE = "it";
const environments: NodeJS.ProcessEnv[] = [];
const mgr = new PiProcessManager(loadConfig({}), {
spawnFn: (_command, _args, options) => {
environments.push(options.env);
return recordingChild() as any;
},
});
try {
mgr.createFor("explicit-language", { interactionLanguage: "en" });
mgr.teardown("explicit-language");
mgr.createFor("manifest-resolved-in-gate");
mgr.teardown("manifest-resolved-in-gate");
expect(environments[0].THT_INTERACTION_LANGUAGE).toBe("en");
expect(environments[1]).not.toHaveProperty("THT_INTERACTION_LANGUAGE");
} finally {
if (previous === undefined) delete process.env.THT_INTERACTION_LANGUAGE;
else process.env.THT_INTERACTION_LANGUAGE = previous;
}
});
test("a close-only child event releases its temporary Pi agent snapshot", () => {
const child = recordingChild();
let snapshotDir: string | undefined;
+169 -42
View File
@@ -53,7 +53,11 @@ const defaultWorkspaceRegistry = {
function buildApp(config: Parameters<typeof buildRealApp>[0], deps: Record<string, unknown> = {}) {
const thtRunner = deps.thtRunner
? { qdrantEnsure: async () => ({ ok: true }), ...(deps.thtRunner as object) }
? {
qdrantEnsure: async () => ({ ok: true }),
ensureInteractionLanguage: async () => ({ interaction_language: "en" }),
...(deps.thtRunner as object),
}
: undefined;
return buildRealApp(config, {
workspaceRuntimeSupport: () => true,
@@ -88,6 +92,129 @@ const aliceHeaders = {
"x-thoth-is-admin": "0",
};
test.each([undefined, null, "", "en--US", "en_US", "en\nIGNORE", "en<script>", 42])(
"new sessions reject invalid interaction language %s before persistence", async (interactionLanguage) => {
const app = mutApp({ sessionNew: async () => { throw new Error("must not create"); } });
try {
const response = await app.inject({
method: "POST", url: "/sessions", payload: { question: "Pazienti", interactionLanguage },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ code: "invalid_interaction_language" });
} finally { await app.close(); }
},
);
test.each([["en", "en"], ["fr-FR", "fr-FR"], ["FR-fr", "fr-FR"]])(
"new session forwards canonical interaction language %s without changing the question", async (language, canonical) => {
let created: any;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
sessionNew: async (options: any) => { created = options; return { id: "language" }; },
searchPack: async () => {},
},
readiness: { ensure: async () => ({ ok: true }) },
mgr: {
get: () => undefined, createFor: () => ({ bridge: { onClientEvent: () => {} } }),
configure: async () => {}, start: () => {},
},
getSettings: () => ({ workspace: "default" }),
});
try {
const response = await app.inject({
method: "POST", url: "/sessions", payload: { question: "Pazienti", interactionLanguage: language },
});
expect(response.statusCode).toBe(200);
expect(created).toMatchObject({ question: "Pazienti", interactionLanguage: canonical });
} finally { await app.close(); }
});
test("resume rejects browser interaction language overrides", async () => {
const app = mutApp({ sessionShow: async () => ({ status: "open", interaction_language: "it" }) });
try {
const response = await app.inject({
method: "POST", url: "/sessions/s1/resume", payload: { interactionLanguage: "en" },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ code: "interaction_language_pinned" });
} finally { await app.close(); }
});
test("resume pins legacy interaction language using the resolved harness workspace", async () => {
let pinnedWorkspace: string | undefined;
let runtime: any;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
sessionShow: async () => ({ id: "legacy", status: "closed" }),
ensureInteractionLanguage: async (_id: string, workspace: string) => {
pinnedWorkspace = workspace;
return { interaction_language: "it" };
},
reopenSession: async () => {},
},
readiness: { ensure: async () => ({ ok: true }) },
mgr: {
get: () => undefined,
createFor: (_id: string, options: any) => {
runtime = options;
return { bridge: { onClientEvent: () => {} } };
},
configure: async () => {}, start: () => {},
},
getSettings: () => ({ workspace: "other-browser-workspace" }),
});
try {
const response = await app.inject({ method: "POST", url: "/sessions/legacy/resume" });
expect(response.statusCode).toBe(200);
expect(pinnedWorkspace).toContain("/default.yaml");
expect(runtime).toMatchObject({ interactionLanguage: "it", mode: "resume" });
} finally { await app.close(); }
});
test("resume retains persisted interaction language despite different browser settings", async () => {
let options: any;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
sessionShow: async () => ({ id: "saved", status: "closed", interaction_language: "it" }),
ensureInteractionLanguage: async () => { throw new Error("language is already pinned"); },
reopenSession: async () => {},
},
readiness: { ensure: async () => ({ ok: true }) },
mgr: {
get: () => undefined,
createFor: (_id: string, value: any) => {
options = value;
return { bridge: { onClientEvent: () => {} } };
},
configure: async () => {}, start: () => {},
},
getSettings: () => ({ workspace: "english-workspace", locale: "en" }),
});
try {
const response = await app.inject({ method: "POST", url: "/sessions/saved/resume" });
expect(response.statusCode).toBe(200);
expect(options.interactionLanguage).toBe("it");
} finally { await app.close(); }
});
test("resume cannot start Pi if legacy interaction language cannot be persisted", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
sessionShow: async () => ({ id: "legacy", status: "closed" }),
ensureInteractionLanguage: async () => { throw new Error("private storage details"); },
reopenSession: async () => { throw new Error("must not reopen"); },
},
readiness: { ensure: async () => ({ ok: true }) },
mgr: { createFor: () => { throw new Error("must not spawn"); } },
getSettings: () => ({ workspace: "default" }),
});
try {
const response = await app.inject({ method: "POST", url: "/sessions/legacy/resume" });
expect(response.statusCode).toBe(503);
expect(response.body).not.toContain("private storage details");
} finally { await app.close(); }
});
test("upstream requests without a principal fail before a Pi runtime can be created", async () => {
let created = false;
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
@@ -95,7 +222,7 @@ test("upstream requests without a principal fail before a Pi runtime can be crea
thtRunner: {} as any,
});
const response = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const response = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(response.statusCode).toBe(401);
expect(created).toBe(false);
@@ -133,7 +260,7 @@ test("maintenance rejects new and resumed session admission without interrupting
});
const create = await app.inject({
method: "POST", url: "/sessions", headers: aliceHeaders, payload: { question: "q" },
method: "POST", url: "/sessions", headers: aliceHeaders, payload: { interactionLanguage: "en", question: "q" },
});
const resume = await app.inject({ method: "POST", url: "/sessions/open/resume", headers: aliceHeaders });
@@ -154,7 +281,7 @@ test("a durable maintenance marker initializes admission closed after backend re
AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness", THT_MAINTENANCE_FILE: marker,
}), { thtRunner: {} as any });
const response = await app.inject({
method: "POST", url: "/sessions", headers: aliceHeaders, payload: { question: "q" },
method: "POST", url: "/sessions", headers: aliceHeaders, payload: { interactionLanguage: "en", question: "q" },
});
expect(response.statusCode).toBe(503);
expect(response.json()).toMatchObject({ code: "maintenance" });
@@ -492,7 +619,7 @@ test("new sessions are created through the authenticated principal, not a client
const response = await app.inject({
method: "POST", url: "/sessions", headers: aliceHeaders,
payload: { question: "q", owner: "mallory" },
payload: { interactionLanguage: "en", question: "q", owner: "mallory" },
});
expect(response.statusCode).toBe(200);
@@ -509,7 +636,7 @@ test("new sessions reject the client legacy workspace field unless local legacy
});
const response = await app.inject({
method: "POST", url: "/sessions", payload: { question: "q", workspace: "legacy" },
method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q", workspace: "legacy" },
});
expect(response.statusCode).toBe(409);
@@ -529,7 +656,7 @@ test("explicit local legacy mode permits the unpinned client workspace request",
});
const response = await app.inject({
method: "POST", url: "/sessions", payload: { question: "q", workspace: "legacy" },
method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q", workspace: "legacy" },
});
expect(response.statusCode).toBe(200);
@@ -567,7 +694,7 @@ test("creates a session from the active immutable workspace revision", async ()
await app.inject({
method: "POST", url: "/sessions",
payload: { question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low" },
payload: { interactionLanguage: "en", question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low" },
});
expect(sessionNew).toHaveBeenCalledWith(expect.objectContaining({
@@ -611,7 +738,7 @@ test("hands one Catalog-backed runtime to both retrieval and Pi", async () => {
const response = await app.inject({
method: "POST",
url: "/sessions",
payload: { question: "Which users placed orders?", workspaceId: "default" },
payload: { interactionLanguage: "en", question: "Which users placed orders?", workspaceId: "default" },
});
expect(response.statusCode).toBe(200);
@@ -687,7 +814,7 @@ test("refuses core admission when the workspace preprocessing fingerprint is sta
const response = await app.inject({
method: "POST",
url: "/sessions",
payload: { question: "q", workspaceId: "default" },
payload: { interactionLanguage: "en", question: "q", workspaceId: "default" },
});
expect(response.statusCode).toBe(409);
@@ -739,7 +866,7 @@ test("rejects an SSH-only Catalog binding before persisting or starting a sessio
} as any,
});
const response = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const response = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({ code: "workspace_not_activatable" });
@@ -779,7 +906,7 @@ test("hands a revision lease to retention only after the session manifest is dur
workspaceRegistry: { acquireSessionRevision } as any,
});
const request = app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const request = app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
await new Promise((resolve) => setImmediate(resolve));
expect(markPersisted).not.toHaveBeenCalled();
expect(abort).not.toHaveBeenCalled();
@@ -810,7 +937,7 @@ test("creates a session from the configured default workspace revision when work
workspaceRegistry: registry as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(registry.read).toHaveBeenCalledWith("psd-clinical");
expect(sessionNew).toHaveBeenCalledWith(expect.objectContaining({
@@ -896,7 +1023,7 @@ test("session lifecycle locates a B session when installation default is A", asy
} as any,
});
expect((await app.inject({ method: "POST", url: "/sessions", payload: {
expect((await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en",
question: "B question", workspaceId: "b-workspace", provider: "zai", model: "glm-5.2", thinking: "low",
} })).statusCode).toBe(200);
expect((await app.inject({ method: "GET", url: "/sessions" })).json()).toEqual([
@@ -939,7 +1066,7 @@ test("POST /sessions uses the catalog default with workspace/thinking settings a
],
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const created = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const created = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(created.json()).toEqual({ id: "s1" });
expect(sessionNewArg.workspaceConfigPath).toContain(`/snapshots/${"e".repeat(40)}/w.yaml`);
expect(sessionNewArg.provider).toBe("zai");
@@ -1000,11 +1127,11 @@ test("POST /sessions stops the user's previous open Pi runtime before creating a
getSettings: () => ({ workspace: "local" }) as any,
});
expect((await app.inject({ method: "POST", url: "/sessions", payload: { question: "one" } })).statusCode)
expect((await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "one" } })).statusCode)
.toBe(200);
order.length = 0;
const second = await app.inject({ method: "POST", url: "/sessions", payload: { question: "two" } });
const second = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "two" } });
expect(second.statusCode).toBe(200);
expect(second.json()).toEqual({ id: "s2" });
@@ -1025,7 +1152,7 @@ test("POST /sessions refuses to create a session when the local DWH precheck fai
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "w" }) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.statusCode).toBe(503);
expect(res.json()).toMatchObject({ code: "dwh_unreachable" });
expect(pinged).toBe(1);
@@ -1046,7 +1173,7 @@ test("POST /sessions proceeds past a passing DWH precheck", async () => {
getSettings: () => ({ workspace: "w" }) as any,
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.statusCode).toBe(200);
expect(pinged).toBe(1);
expect(created).toBe(1);
@@ -1065,7 +1192,7 @@ test("POST /sessions skips the DWH precheck when the flag is off (default)", asy
getSettings: () => ({ workspace: "w" }) as any,
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.statusCode).toBe(200);
expect(pinged).toBe(0); // probe never runs without the flag
});
@@ -1099,7 +1226,7 @@ test("POST /sessions configura Pi con il thinking globale selezionato", async ()
],
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
await new Promise((resolve) => setImmediate(resolve));
expect(configured.thinking).toBe("high");
@@ -1427,7 +1554,7 @@ test("resuming a different session stops the user's previous Pi runtime", async
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "local" }) as any,
});
expect((await app.inject({ method: "POST", url: "/sessions", payload: { question: "one" } })).statusCode)
expect((await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "one" } })).statusCode)
.toBe(200);
const resumed = await app.inject({ method: "POST", url: "/sessions/s2/resume" });
@@ -1815,13 +1942,13 @@ test.each([
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "old" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "old" } });
if (lifecycle === "close") {
await app.inject({ method: "POST", url: "/sessions/s1/close" });
await app.inject({ method: "POST", url: "/sessions/s1/resume" });
} else {
await app.inject({ method: "DELETE", url: "/sessions/s1" });
await app.inject({ method: "POST", url: "/sessions", payload: { question: "replacement" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "replacement" } });
}
await new Promise((resolve) => setImmediate(resolve));
expect(current).toBe(replacement);
@@ -1885,7 +2012,7 @@ test.each(["resolve", "reject"] as const)(
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "old" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "old" } });
await app.inject({ method: "DELETE", url: "/sessions/s1" });
published.length = 0;
@@ -2077,7 +2204,7 @@ test("Close suppresses a bootstrap that settles while close persistence is pendi
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
const closeResponse = app.inject({ method: "POST", url: "/sessions/s1/close" });
await closeStarted;
published.length = 0;
@@ -2151,7 +2278,7 @@ test("bootstrap failure persists once and keeps Resume serialized behind that pe
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
oldConfigure.reject(new Error("configure failed"));
await failureStarted;
const resumeResponse = app.inject({ method: "POST", url: "/sessions/s1/resume" })
@@ -2274,7 +2401,7 @@ test("a replaced runtime cannot publish or fail the newly resumed session", asyn
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
await new Promise((resolve) => setImmediate(resolve));
oldBridge.setState("idle");
@@ -2334,7 +2461,7 @@ test("a deleted runtime cannot repopulate or fail the forgotten session", async
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
await new Promise((resolve) => setImmediate(resolve));
const response = await app.inject({ method: "DELETE", url: "/sessions/s1" });
@@ -2379,7 +2506,7 @@ test("an unexpectedly exited runtime publishes its terminal sequence then releas
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
await new Promise((resolve) => setImmediate(resolve));
published.length = 0;
@@ -2430,7 +2557,7 @@ test("agent_end releases the Pi runtime after the session was finalized", async
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
await new Promise((resolve) => setImmediate(resolve));
bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
@@ -2502,7 +2629,7 @@ test("POST /sessions/:id/response senza gate pendente risponde 409 (risposta sta
getSettings: () => ({ workspace: "w" }),
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
// The fake Pi never emitted a ui_request: the bridge has no pending descriptor, so a
// response (stale UI, double submit) must be rejected instead of forwarded to Pi.
const res = await app.inject({ method: "POST", url: "/sessions/s1/response",
@@ -2643,7 +2770,7 @@ test("POST /sessions readiness failure returns one fixed public message without
getSettings: () => ({ workspace: "psd" }) as any,
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.statusCode).toBe(503);
expect(res.json()).toEqual({
error: "Session services are not ready. Check configuration and connectivity, then try again.",
@@ -2664,7 +2791,7 @@ test.each(["semantic_index_incompatible", "workspace_not_activatable"] as const)
});
const response = await app.inject({
method: "POST", url: "/sessions", payload: { question: "q" },
method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" },
});
expect(response.statusCode).toBe(503);
@@ -2687,7 +2814,7 @@ test("POST /sessions returns storage 503 before creating a Pi runtime when sessi
mgr: { createFor: () => { piCreated = true; throw new Error("must not spawn"); } } as any,
});
const response = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const response = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(response.statusCode).toBe(503);
expect(response.json()).toEqual({ error: "session storage is unavailable" });
@@ -2707,7 +2834,7 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => {
getSettings: () => ({ workspace: "psd" }) as any,
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.json()).toEqual({ id: "s1" });
expect(qdrantEnsure).toHaveBeenCalledWith(operationalWorkspace("psd"), 60, "self_heal");
expect(ensureWs).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`);
@@ -2740,7 +2867,7 @@ test("POST /sessions rejects a stale requested model without silently using the
const res = await app.inject({
method: "POST",
url: "/sessions",
payload: { question: "q", provider: "deepseek", model: "deepseek-v4-pro" },
payload: { interactionLanguage: "en", question: "q", provider: "deepseek", model: "deepseek-v4-pro" },
});
expect(res.statusCode).toBe(503);
@@ -2775,7 +2902,7 @@ test("POST /sessions marks a persisted session failed when runtime construction
],
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.statusCode).toBe(503);
expect(res.json()).toEqual({
@@ -2868,7 +2995,7 @@ test.each([
try {
const response = flow === "new"
? await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } })
? await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } })
: await app.inject({ method: "POST", url: `/sessions/${sessionId}/resume` });
const logs = consoleError.mock.calls.flat().map(String).join(" ");
@@ -2974,7 +3101,7 @@ test("POST /sessions returns after bridge attachment but starts only after retri
getSettings: () => ({ workspace: "psd" }) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.json()).toEqual({ id: "s-early" });
expect(bridgeAttached).toBe(true);
expect(started).toBe(false);
@@ -3021,7 +3148,7 @@ test("POST /sessions bootstrap failure emits only a fixed recovery message", asy
const response = await app.inject({
method: "POST",
url: "/sessions",
payload: { question: "q" },
payload: { interactionLanguage: "en", question: "q" },
});
await new Promise((resolve) => setImmediate(resolve));
@@ -3117,7 +3244,7 @@ test.each([
})),
} as any,
});
const response = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const response = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(response.statusCode).toBe(expectedStatus);
expect(ensure).toHaveBeenCalledTimes(reachesReadiness ? 1 : 0);
+13
View File
@@ -34,6 +34,19 @@ test("sessionNew parses id from JSON", async () => {
expect(await r.sessionNew({ question: "q" })).toEqual({ id: "2026-06-27-100000-x" });
});
test("session language uses public per-command CLI flags and the selected config", async () => {
const runner = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
(spawn as any).mockClear();
await runner.sessionNew({ question: "Pazienti", interactionLanguage: "en" });
expect((spawn as any).mock.calls[0][1]).toEqual([
"session", "new", "Pazienti", "--interaction-language", "en", "--json", "-c", "config/tht.yaml",
]);
await runner.ensureInteractionLanguage("s1");
expect((spawn as any).mock.calls[1][1]).toEqual([
"session", "ensure-interaction-language", "s1", "--json", "-c", "config/tht.yaml",
]);
});
test("searchPack persists retrieval context with session and workspace", async () => {
const calls: any[] = [];
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
+21 -1
View File
@@ -1,6 +1,26 @@
#!/bin/sh
set -eu
test "$(cat /usr/share/nginx/html/config.js)" = 'window.__THOTHII_CONFIG__ = {};'
# The generic image contains an empty fallback; installed containers mount the generated
# public projection over it. An optional path lets host projection tests use this same check.
config_file=${1:-/usr/share/nginx/html/config.js}
test -f "$config_file" && test -r "$config_file"
config=$(tr -d '[:space:]' < "$config_file")
case "$config" in
'window.__THOTHII_CONFIG__={};')
test -z "${THT_FRONTEND_CONFIG_REVISION:-}"
;;
*)
# Installation Load validates BCP47 syntax. Here check only the public payload shape;
# catalog availability and fallback belong to the frontend, not the generic image.
locale='[A-Za-z][A-Za-z0-9]*(-[A-Za-z0-9]+)*'
full="\"mode\":\"full\",\"defaultLocale\":\"$locale\""
embedded="\"mode\":\"embedded\",\"defaultLocale\":\"$locale\",\"adapter\":\"omics-portal\""
if ! printf '%s\n' "$config" | LC_ALL=C grep -Eq "^window\.__THOTHII_CONFIG__=\{\"backendBaseUrl\":\"/api\",\"shell\":\{($full|$embedded)\}\};$"; then
echo "Invalid frontend public runtime configuration" >&2
exit 1
fi
;;
esac
printf '%s\n' "frontend runtime config smoke: ok"
@@ -0,0 +1,101 @@
# ADR 0021 — Shell separati e adapter sostituibile per il portale
- Stato: accettato
- Data: 2026-09-13
## Decisione
ThothII espone due modalità di installazione, selezionate da `shell.mode`:
- `embedded` (default): ThothII è ospitato da Omics Portal. Non renderizza alcun header e
riceve dal portale lingua, tema e fullscreen. L'accesso resta verificato dal server.
- `full`: ThothII è autonomo. Renderizza il proprio header, con selettore lingua, tema,
fullscreen e nome utente. Il click sul nome apre il logout. Non mostra mai la rotellina o
altri comandi amministrativi del portale. Mantiene un rail vuoto a sinistra di almeno 20 px.
Il fatto che la shell sia `full` è distinto dallo stato `fullscreen`: la prima decide quale
contenitore viene renderizzato, il secondo indica se è attiva la Fullscreen API del browser.
L'icona passa da “entra in fullscreen” a “torna alla modalità normale”; anche `Esc` aggiorna lo
stato visualizzato.
La configurazione installata resta semplice e retrocompatibile. Sul Mac di sviluppo il profilo
locale userà:
```yaml
shell:
mode: full
defaultLocale: en
```
Il deploy sul server userà invece:
```yaml
shell:
mode: embedded
adapter: omics-portal
```
`defaultLocale` indica la lingua iniziale della shell full; in embedded la fonte autorevole resta
il portale.
L'adapter è l'unico confine tra ThothII e il portale. La sua interfaccia pubblica è volutamente
profonda e minima: consegna solo snapshot dello stato, senza esporre comandi, token, identità o
dettagli di trasporto.
```ts
export type HostShellState = {
locale: string; // BCP-47, inizialmente it/en
theme: "light" | "dark";
fullscreen: boolean;
};
export interface PortalAdapter {
subscribe(
onState: (state: HostShellState) => void,
onError: (error: Error) => void,
): () => void;
}
```
`OmicsPortalAdapter` è l'implementazione corrente. Un adapter per un altro portale potrà
sostituirlo senza modificare shell, i18n o workflow. In `full` l'adapter non viene istanziato:
lo stato è gestito internamente dalla shell.
Per l'integrazione oggi operativa, che monta la SPA direttamente nel DOM di Omics Portal,
l'adapter legge la lingua effettiva dal selettore Omics, osserva l'attributo del tema e ascolta
il fullscreen del documento. Selettori e osservatori restano privati dell'implementazione Omics.
La lingua segue il normale ricaricamento Django; tema e fullscreen cambiano nella pagina aperta.
La revisione approvata del 2026-09-13 elimina il precedente handshake a eventi: non servono
messaggi personalizzati, versioni di trasporto o timeout di avvio. Un futuro adapter potrà usare
un diverso trasporto senza modificare l'interfaccia applicativa.
Se `shell` o l'adapter embedded sono omessi, si usa `embedded` con `omics-portal`. Questo default
supporta il documento Omics esistente; nomi adapter sconosciuti o dati host mancanti producono
un errore esplicito, senza attivare la shell full.
## Confini che restano invariati
L'identità e l'autorizzazione del backend non vengono ricostruite nel browser. In embedded,
Omics Portal continua a gestire login e logout e la catena server-side `auth_request` continua a
fornire i principal header già previsti. Lo stato UI non dichiara l'utente autenticato: il modulo
di accesso usa la verifica backend esistente anche alla riconnessione e al ritorno alla pagina.
Un rifiuto su una singola operazione non equivale automaticamente alla perdita dell'accesso.
La lingua UI e la lingua di interazione con il modello restano separate dalla lingua del
workspace; il relativo contratto è in [ADR 0022](0022-separate-ui-locale-from-session-interaction-language.md).
## Alternative scartate
- Duplicare l'header di Omics in embedded: crea due fonti di stato e incompatibilità visive.
- Spargere controlli `if embedded/full` nei componenti: lega ogni pagina al portale.
- Trasmettere utente o token nel bridge: aumenta superficie e accoppia UI e autenticazione.
- Introdurre un protocollo completo request/response: non aggiunge funzionalità richiesta.
- Usare `profile` per distinguere le shell: `profile` descrive la topologia dell'installazione,
non la sua presentazione.
## Conseguenze
La soluzione richiede un adapter nel frontend che osserva il documento condiviso e mantiene la
logica di shell locale a ThothII. Il backend non necessita di un nuovo protocollo di autenticazione
o di una nuova sessione browser. Un nuovo portale deve soddisfare anche il contratto server di
identità fidata: la sola sostituzione della classe UI non sostituisce quel contratto.
@@ -0,0 +1,51 @@
# Separate UI locale from session interaction language
status: accepted
ThothII distinguishes three language concepts:
- `workspace.language` remains the language of workspace-owned documents, descriptions and Evidence;
- `ui_locale` controls deterministic ThothII chrome such as labels, form help, placeholders, errors,
accessibility text and review-widget chrome;
- `interaction_language` is persisted in a session and controls model-generated questions,
explanations and reviewer proposals.
The initial locale catalog supports Italian and English and uses extensible BCP-47 language tags.
Missing deterministic translations fall back to English. The selected UI locale supplies the default
interaction language when a new session is created. A resumed session always uses its persisted
interaction language; changing the host or full-shell UI locale must not silently rewrite an existing
session or make its model output switch language mid-workflow.
The distinction is required because the current workspace contract already uses `language` for
content and the PSD workspace is Italian. Reusing that field for a browser preference would make a
visual choice mutate domain content semantics. The model receives the session interaction language
through the session/Pi workflow context. SQL, identifiers, database values and other technical
artifacts remain governed by their existing contracts and are not translated as UI strings.
In `full`, the local shell owns `ui_locale` and supplies it when starting a new session. In
`embedded`, the host adapter is authoritative for `ui_locale`; ThothII applies host changes to
deterministic UI immediately while preserving the interaction language of any active session.
We considered using only `workspace.language`, using only a global browser locale, and translating
the model output after generation. The first conflates domain content with UI preference; the second
cannot preserve a session's language or follow the host portal; and the third would be unsafe for
structured reviewer decisions and would not control the model's reasoning or proposal language.
## Considered Options
- One mutable `language` field for workspace, UI and session was rejected because the fields have
different owners and lifecycles.
- Client-only translation of reviewer choices was rejected because choices can be generated by the
model and must be requested in the intended language.
- An English-only deterministic chrome was rejected because embedded and full installations must
follow the selected host/user language.
## Consequences
- Session creation and the persisted manifest gain an explicit interaction-language value.
- Legacy manifests without that value use the workspace language, pinned idempotently on first
resume; the browser locale must not determine this compatibility value.
- Resume must read that value from the manifest and must not accept a new locale as an override.
- The workflow prompt contract and deterministic reviewer-widget builders need a locale-aware input.
- Frontend strings need a catalog and stable keys; backend events should expose stable codes where
the frontend is responsible for localization.
+110
View File
@@ -0,0 +1,110 @@
# Portal Shell Adapter v1
Contratto minimo della presentazione embedded. La revisione approvata il 2026-09-13
sostituisce il precedente trasporto a eventi personalizzati con l'osservazione del
documento condiviso. Non trasferisce identità, token o stato di autenticazione.
## Configurazione
Sul Mac:
```yaml
shell:
mode: full
defaultLocale: en
```
Sul server Omics:
```yaml
shell:
mode: embedded
adapter: omics-portal
```
Se `shell` o `mode` sono omessi, la modalità è embedded. L'adapter embedded
predefinito è `omics-portal`; un nome sconosciuto è un errore di configurazione.
Full non istanzia adapter. `defaultLocale` inizializza full; in embedded il locale
proviene dal portale. L'autenticazione si configura separatamente dalla shell.
## API applicativa
```ts
export type PortalSnapshot = {
locale: string;
theme: "light" | "dark";
fullscreen: boolean;
};
export interface PortalAdapter {
subscribe(
onState: (state: PortalSnapshot) => void,
onError: (error: Error) => void,
): () => void;
}
```
Una sottoscrizione installa gli osservatori e consegna lo snapshot iniziale senza
richiedere messaggi all'altro applicativo. Gli aggiornamenti contengono snapshot
completi e validati. La disiscrizione elimina tutti i listener e osservatori.
La lingua viene risolta tramite i cataloghi UI, con fallback inglese.
## Implementazione Omics
L'integrazione monta React nel documento Django, non in un iframe.
| Dato | Fonte privata dell'adapter | Aggiornamento |
| --- | --- | --- |
| Locale | `data-lang` del selettore `.omics-language-select` | nuova pagina Django dopo `set_language` |
| Tema | `data-bs-theme` su `html` | osservazione limitata a quell'attributo |
| Fullscreen | stato effettivo del documento | evento del browser, inclusa uscita con Esc |
L'attributo `lang` storicamente fisso a `en` nel template base non deve essere
usato come surrogato della lingua selezionata. Leggere il valore renderizzato dal
server evita anche di anticipare un cambio lingua prima che il form abbia successo.
L'assenza del contesto host atteso produce un errore di integrazione; non abilita
controlli locali. Non si introducono eventi `ready/state`, handshake, timeout,
versioni dei messaggi o comandi duplicati. Selettori e dettagli Omics non devono
essere letti dai componenti applicativi.
## Proprietà per modalità
| Funzione | Full | Embedded |
| --- | --- | --- |
| Header | ThothII | solo Omics |
| Lingua | selettore locale | selettore Omics, normale reload Django |
| Tema | toggle locale light/dark | stato Omics |
| Fullscreen | controllo locale, stato reale | controllo Omics, stato reale |
| Login/logout | autenticazione ThothII configurata | autenticazione Omics esistente |
| Nome utente | header ThothII | header Omics |
| Rotellina amministrativa | mai | eventuale comando del portale |
## Accesso e continuità
Il server Omics verifica l'accesso a Datamart Builder e il proxy trasmette i
principal header normalizzati al backend ThothII. La UI usa `/me`; non effettua
un secondo login. Un altro portale deve soddisfare anche questo contratto server,
oltre a fornire una nuova implementazione dell'adapter UI.
Il logout del portale segue la sua navigazione. Una perdita di accesso rilevata
dal server chiude lo stato protetto; un 403 di una singola operazione non equivale
automaticamente a logout. La riconnessione degli eventi e il ritorno alla pagina
ricontrollano l'accesso. Non si garantisce revoca istantanea di una connessione
aperta in un'altra scheda attraverso il solo controllo iniziale del proxy.
Il cambio lingua può ricaricare la pagina: conservare la selezione della sessione,
proteggere le modifiche non salvate e non avviare una nuova generazione al reload.
Non si conserva una trascrizione integrale nel browser. La lingua della sessione
rimane quella registrata nel manifest, secondo ADR 0022.
## Sostituzione e verifiche
Un nuovo adapter può usare un diverso documento o trasporto, ma deve rispettare
la stessa sottoscrizione e mantenere la conoscenza del portale nella propria
implementazione. Non occorre implementare ora iframe o un secondo portale.
Verificare snapshot prima/dopo il montaggio, tema, fullscreen con Esc, cleanup,
contesto host mancante, assenza di header ThothII embedded, accesso singolo,
locale dopo reload e compatibilità del prefisso API. Full deve funzionare senza
alcun elemento Omics presente.
+186
View File
@@ -0,0 +1,186 @@
# Shell, autenticazione e lingue
Questa guida accompagna la [specifica approvata](../plans/2026-09-13-full-shell-spec.md)
e il [contratto Portal Shell Adapter](../contracts/portal-shell-adapter-v1.md).
## Scegliere il contenitore
La modalità della shell è indipendente dal profilo di distribuzione e dal metodo
di autenticazione. Un server può ospitare full; un ambiente locale può ospitare
embedded per provare un'integrazione.
Per questo Mac, nel descrittore installato:
```yaml
shell:
mode: full
defaultLocale: en
```
Per il server Omics:
```yaml
shell:
mode: embedded
adapter: omics-portal
```
L'assenza di `shell` conserva embedded con adapter Omics. Un nome adapter
sconosciuto è un errore, non una richiesta di fallback a full. Full ignora
l'adapter Omics riconosciuto e non lo istanzia. Un locale ben formato per cui non
esiste ancora un catalogo usa l'inglese nell'interfaccia.
`defaultLocale` è il valore iniziale, non un vincolo che annulla ogni scelta
dell'utente. Full ricorda lingua e tema nel browser; embedded segue soltanto
Omics. Le preferenze non modificano il descrittore installato.
## Applicare una modifica all'installazione
Aggiornare prima il binario nativo `tht`: le versioni precedenti rifiutano la
sezione `shell`. Modificare poi il descrittore e generare le proiezioni:
```bash
tht --installation /percorso/assoluto/thothii-installation.yaml installation generate
```
Il comando non avvia né arresta servizi. Genera anche
`generated/frontend/config.js`, che contiene configurazione pubblica, e il
relativo mount Compose. Non modificare a mano i file generati. `tht start`
rigenera le proiezioni nel normale percorso di avvio.
Applicare il normale processo di aggiornamento dei container dell'installazione.
Se si usa un launcher Compose personalizzato, deve includere la proiezione
Compose generata e ricreare il frontend quando cambia la configurazione. Il
fingerprint della configurazione pubblica permette a Compose di rilevare il cambio.
La stessa immagine frontend supporta entrambe le modalità.
Controllare il `config.js` effettivamente servito, che non deve essere memorizzato
in cache. In full la route API ordinaria è `/api`; Omics imposta nel template il
prefisso same-origin `/datamart-builder/api`, mantenendo le altre impostazioni.
## Accesso in parole semplici
In Omics l'utente effettua l'accesso al portale come oggi. Quando sceglie
Datamart Builder, il server controlla che possa usarlo e comunica a ThothII chi è.
ThothII apre l'applicazione per quella persona: non presenta un altro login e non
crea una seconda sessione browser. Le password non vengono trasmesse a ThothII.
Il nome e il comando Esci rimangono nell'header del portale.
Sul Mac full, ThothII presenta il proprio login locale. Dopo l'accesso mostra il
nome nell'header; il menu del nome contiene il logout. Riutilizza gli utenti e
la configurazione di accesso dell'installazione. Full supporta anche un'eventuale
autenticazione OIDC configurata; il suo logout termina la sessione ThothII, non
promette di disconnettere l'utente da tutti gli altri servizi OIDC.
I controlli server restano autorevoli. Un errore su una singola operazione non
deve cancellare automaticamente l'accesso all'intera applicazione. Un rifiuto
della verifica dell'utente chiude invece lo stato protetto. L'accesso viene
ricontrollato anche al ritorno alla pagina e quando il collegamento eventi deve
riconnettersi. Questo non equivale a una revoca istantanea di ogni connessione
già aperta in altre schede.
## Contratto server Omics
Il percorso corrente usa il controllo Django della capability
`datamart_builder.access`, la subrequest nginx `auth_request` e gli header
normalizzati `X-Thoth-Principal-Issuer`, `X-Thoth-Principal-Subject`,
`X-Thoth-Principal-Display-Name` e `X-Thoth-Is-Admin`. Il browser non può
scegliere queste identità: il proxy ricava gli header dal controllo server e
sostituisce quelli eventualmente forniti dal client.
Mantenere il backend configurato per l'autenticazione upstream e i suoi controlli
di autorizzazione. Non esporre un percorso alternativo che permetta al browser
di raggiungerlo aggirando quel controllo. Non introdurre token nel documento,
negli eventi UI o nella configurazione pubblica.
## Come funziona l'adapter
`OmicsPortalAdapter` è il solo modulo frontend che conosce il documento Omics.
Legge il `data-lang` del selettore lingua, osserva `data-bs-theme` e ascolta lo
stato fullscreen del documento. Fornisce snapshot `{ locale, theme, fullscreen }`
al controller di shell. Non invia comandi al portale e non usa un handshake.
La pagina Omics deve continuare a esporre il selettore
`select.omics-language-select` con la lingua renderizzata nel suo `data-lang`, e
il tema light/dark nell'attributo di `html`. Il selettore lingua usa il normale
form Django; non occorre convertirlo in una richiesta asincrona.
Per un altro portale, implementare la stessa sottoscrizione e selezionare il
nuovo adapter nel punto di composizione. Le pagine, l'i18n e il workflow non
devono acquisire riferimenti al nuovo portale. Sul lato server, il nuovo
contenitore deve anche fornire un'identità verificata conforme al contratto
upstream. Cambiare una classe JavaScript non sostituisce quel requisito.
## Lingue e sessioni
Ci sono tre scelte distinte:
| Scelta | Dove viene conservata | Cosa influenza |
| --- | --- | --- |
| Lingua UI | preferenza full o stato Omics | label, form, messaggi e controlli |
| Lingua di interazione | `interaction_language` nel manifest | nuove domande, spiegazioni e scelte del modello |
| Lingua workspace | configurazione del workspace | documenti, descrizioni e contenuti di dominio |
La creazione web acquisisce la lingua UI prima delle operazioni asincrone e la
invia come `interactionLanguage`. Un cambio successivo non modifica quella
richiesta. La ripresa legge il manifest e non usa il locale del browser come
override. SQL, identificatori, valori e citazioni dei contenuti rimangono invariati.
Per sessioni precedenti senza `interaction_language`, la prima ripresa fissa la
lingua del workspace in modo idempotente. Se il workspace era stato modificato
nel frattempo, non esiste una registrazione da cui ricostruire con certezza la
vecchia lingua: il criterio di compatibilità è quella disponibile alla ripresa.
Il cambio lingua di Omics ricarica la pagina. ThothII ricorda soltanto l'identificatore
della sessione per utente e pagina, senza salvare una trascrizione nel browser.
Il recupero riapre il pannello dei documenti; la ripresa operativa è esplicita e
non avvia una generazione soltanto perché la pagina è stata ricaricata. Le bozze
non inviate e le modifiche amministrative richiedono protezione dalla navigazione.
## Aggiungere e verificare traduzioni
I messaggi inglesi fungono da identificatori gettext-style e fallback. I
cataloghi italiani sono divisi per area per agevolarne la manutenzione. Usare
`useI18n()` nei componenti e interpolazioni nominate, evitando concatenazioni
che rendano impossibile cambiare l'ordine delle parole. Non chiamare il traduttore
su SQL, testi del modello o descrizioni del workspace.
Per una nuova lingua aggiungere il catalogo, registrarlo nel risolutore e renderlo
disponibile nel selettore full. Il portale deve fornire il relativo locale. La
lingua delle sessioni è già esplicita e non richiede una nuova struttura del manifest.
Le traduzioni dei controlli della griglia provengono dal catalogo ufficiale della
stessa versione di AG Grid.
```bash
cd frontend
npm run check:i18n
npx tsc -b
npx vitest run
```
Il controllo dei cataloghi segnala messaggi statici mancanti, interpolazioni
incompatibili e traduzioni discordanti. Non può provare da solo la copertura di
tutti i messaggi dinamici: completarlo con i test delle pagine e la verifica visiva.
La build verifica anche il CSS effettivamente generato: il reset Tailwind viene
limitato al mount React e ai suoi popup con selettori ordinari, senza richiedere
supporto browser a `@scope`. Mantenere letterali le classi dei due modi della shell
per conservarle durante la rimozione del CSS inutilizzato. Le griglie usano il tema
CSS esistente con i token light/dark; non mescolarlo con la nuova Theming API di AG Grid.
## Verifica prima del deploy server
Provare l'apertura dal menu Omics con un utente autorizzato e uno senza accesso;
verificare assenza di un secondo login e di header ThothII, italiano/inglese già
selezionati prima dell'apertura, tema, fullscreen e uscita con Esc. Ripetere con
un menu o un form aperto, una bozza non inviata e una sessione esistente.
Verificare perdita dell'accesso, ritorno alla scheda e riconnessione degli eventi;
distinguere questi casi dal rifiuto di una sola operazione. Controllare che una
ripresa conservi la lingua salvata e che il reload non avvii una nuova generazione.
Eseguire i test Omics nel suo ambiente Docker e includere gli aggiornamenti
dei template, degli asset e dei cataloghi Django nel suo normale rebuild.
La verifica del codice e i test locali non costituiscono un deploy sul server
di produzione. Usare il normale processo di rilascio per applicare entrambe le
revisioni e annotare immagini, descrittore e revisioni realmente installate.
@@ -0,0 +1,91 @@
# Piano — Full shell e integrazione con il portale
Stato: implementazione completata e installata sul Mac; deploy server non eseguito.
Esiti e limiti del collaudo: [rapporto di verifica](../reports/2026-09-13-full-shell-implementation.md).
Specifica consolidata: [Full shell, integrazione Omics e interfaccia bilingue](2026-09-13-full-shell-spec.md),
pubblicata come [specifica su Gitea](https://git.tylconsulting.it/mptyl/ThothII/issues/32).
Ticket approvati: [Full sul Mac](https://git.tylconsulting.it/mptyl/ThothII/issues/33),
[Embedded Omics](https://git.tylconsulting.it/mptyl/ThothII/issues/34),
[Sessioni bilingui](https://git.tylconsulting.it/mptyl/ThothII/issues/35),
[Traduzione completa](https://git.tylconsulting.it/mptyl/ThothII/issues/36),
[Consegna verificata](https://git.tylconsulting.it/mptyl/ThothII/issues/37).
Le dipendenze sono registrate anche nativamente sul tracker.
## Obiettivo
Aggiungere una shell autonoma `full` mantenendo compatibile l'integrazione corrente `embedded`
con Omics Portal. La complessità del portale deve restare confinata a un `PortalAdapter`.
## Regole definitive
- `embedded` è il default e non renderizza alcun header ThothII.
- La configurazione installata su questo Mac imposta `shell.mode: full` e
`shell.defaultLocale: en`.
- Il deploy server imposta `shell.mode: embedded` e `shell.adapter: omics-portal`.
- `full` renderizza header, rail sinistro vuoto di almeno 20 px e logout locale.
- Fullscreen è uno stato separato dalla shell: il pulsante entra/esce dalla Fullscreen API e
l'icona riflette anche l'uscita con `Esc`.
- Full include lingua, tema `light/dark`, fullscreen e nome utente; non include la rotellina
amministrativa.
- Embedded riceve dal portale solo `locale`, `theme` e `fullscreen`; il server verifica l'accesso.
- Login e logout embedded restano responsabilità di Omics Portal.
- La lingua UI è separata dalla lingua di interazione fissata nella sessione.
## Sequenza di implementazione
### 1. Configurazione e stato shell
- Aggiungere `shell.mode`, `shell.defaultLocale` e `shell.adapter` al descrittore di
installazione, mantenendo `embedded` come default quando `shell` non è presente.
- Proiettare nel runtime frontend solo configurazione non segreta.
- Centralizzare lo stato shell in un controller; i componenti non devono leggere direttamente
il portale.
### 2. Adapter e bridge Omics
- Implementare `PortalAdapter` con la sola API `subscribe`.
- Implementare `OmicsPortalAdapter` osservando il documento condiviso, secondo il contratto rivisto.
- Leggere il locale dal selettore renderizzato da Django, osservare il tema e ascoltare il fullscreen.
- Conservare il cambio lingua tramite reload Omics e il percorso server di autenticazione esistente.
- Validare snapshot e cleanup; in caso di errore mostrare un messaggio di integrazione in
embedded, senza fallback a controlli locali.
### 3. i18n e lingua del modello
- Introdurre cataloghi UI estendibili, inizialmente `it` e `en`, con fallback inglese.
- Usare il locale corrente per le nuove sessioni.
- Persistire nel manifest la lingua di interazione e usarla per domande, spiegazioni e scelte
del revisore; una ripresa conserva quella lingua.
- Non tradurre SQL, identificatori o contenuti del workspace.
### 4. Shell full
- Renderizzare header solo in `full`.
- Collegare selettore lingua, tema, fullscreen, nome utente e logout alle funzioni già esistenti
o equivalenti del frontend/backend.
- Implementare il cambio icona e la sincronizzazione con `fullscreenchange`.
- Applicare il rail sinistro vuoto con larghezza base semplice e non inferiore a 20 px.
### 5. Verifica
- Unit test per validazione adapter, cambio stato, fallback locale e regole di sessione.
- Test frontend per entrambe le shell, logout full e fullscreen con `Esc`.
- Test backend per il passaggio della lingua nelle nuove sessioni e la conservazione in resume.
- Test di integrazione Omics per preferenze iniziali, tema, fullscreen, locale dopo reload e accesso.
- Build frontend/backend, suite harness e build documentale.
## Fuori ambito
- Cambiare il proxy/authentication chain già operativo.
- Passare a iframe o introdurre `postMessage`.
- Trasferire token o oggetti utente nel browser bridge.
- Aggiungere una shell `system` per il tema o un terzo tema.
- Implementare un adapter per un secondo portale: deve solo essere possibile sostituire quello
Omics tramite la stessa interfaccia.
## Gate di approvazione
L'implementazione è approvabile quando il codice rispetta il [contratto v1](../contracts/portal-shell-adapter-v1.md),
la modalità embedded non mostra header proprio e la modalità full non dipende da Omics Portal.
+99
View File
@@ -0,0 +1,99 @@
# Full shell, integrazione Omics e interfaccia bilingue
## Problem Statement
ThothII è utilizzabile nel contenitore di Omics Portal, ma quando viene avviato
autonomamente deve offrire i comandi generali che oggi appartengono al portale.
Gli utenti devono poter scegliere italiano o inglese per l'interfaccia e per le
nuove conversazioni con il modello, senza modificare la lingua dei contenuti del
workspace. L'integrazione server deve conservare l'accesso già effettuato in Omics.
## Solution
Due modalità di installazione: Full Thoth Shell con header autonomo e Embedded
Thoth Shell pilotata dall'header del portale. Sul Mac si installa full con inglese
predefinito. Un Portal Shell Adapter sostituibile concentra le conoscenze Omics;
l'autenticazione utilizza i percorsi già esistenti. La lingua di interazione
rimane fissata per tutta la durata di una sessione, comprese le riprese.
## User Stories
1. As an operatore, I want scegliere full o embedded durante l'installazione, so that il contenitore corrisponda al luogo di utilizzo.
2. As an operatore Mac, I want full con inglese predefinito, so that l'applicazione sia autonoma appena aperta.
3. As an operatore di un'installazione precedente, I want mantenere embedded senza aggiungere configurazioni obbligatorie, so that un aggiornamento non interrompa l'integrazione Omics.
4. As an utente full, I want un header con lingua, tema, fullscreen e nome utente, so that i comandi generali siano sempre raggiungibili.
5. As an utente full, I want una fascia sinistra vuota di almeno 20 px bilanciata con lo spazio destro, so that il contenuto abbia margini coerenti.
6. As an utente full, I want nessuna rotellina amministrativa del portale, so that il contenitore mostri solo i comandi richiesti.
7. As an utente full, I want aprire il logout dal nome utente, so that possa terminare il mio accesso.
8. As an utente locale, I want usare le credenziali ThothII esistenti, so that non debba configurare Omics.
9. As an utente full con OIDC, I want terminare la sessione ThothII, so that il logout non sia limitato al login locale.
10. As an utente Omics, I want aprire Datamart Builder già autenticato, so that non debba effettuare un secondo accesso.
11. As an utente embedded, I want un solo header fornito da Omics, so that non compaiano comandi duplicati.
12. As an utente embedded, I want che login e logout siano gestiti dal portale, so that l'accesso sia coerente con le altre pagine.
13. As an utente embedded, I want che ThothII recepisca le preferenze già impostate prima dell'apertura, so that lingua e tema siano subito corretti.
14. As an utente, I want scegliere light o dark, so that la leggibilità corrisponda alle condizioni ambientali.
15. As an utente, I want leggere form, menu, errori e finestre anche in dark, so that il tema sia completo.
16. As an utente full, I want entrare in fullscreen del browser, so that il browser lasci spazio all'applicazione.
17. As an utente, I want vedere l'icona di uscita quando il fullscreen è attivo e l'icona iniziale dopo Esc, so that il comando rappresenti lo stato effettivo.
18. As an utente, I want un messaggio comprensibile se il browser rifiuta il fullscreen, so that il controllo non mostri uno stato inesistente.
19. As an utente, I want tutte le label e i testi non generati dal modello in italiano o inglese, so that possa usare l'applicazione nella lingua scelta.
20. As an utente assistito da lettore di schermo, I want nomi accessibili e messaggi tradotti, so that i controlli siano utilizzabili quanto quelli visivi.
21. As an utente embedded, I want il cambio lingua segua il normale ricaricamento Omics, so that tutta la pagina condivida la lingua.
22. As an revisore, I want ritrovare la sessione dopo quel ricaricamento e proteggere le modifiche non salvate, so that non perda il lavoro.
23. As an revisore, I want le nuove domande e scelte del modello nella lingua UI selezionata, so that l'interazione sia comprensibile.
24. As an revisore, I want riprendere una sessione nella sua lingua originale, so that le preferenze del nuovo browser non cambino il workflow.
25. As an revisore di sessioni precedenti, I want una regola stabile per i manifest privi della lingua di interazione, so that le riprese restino prevedibili.
26. As an responsabile dei dati, I want conservare lingua e contenuto di workspace, SQL, identificatori e valori, so that una preferenza UI non alteri i dati.
27. As an manutentore, I want aggiungere cataloghi per altre lingue con fallback inglese, so that l'i18n sia estendibile.
28. As an integratore, I want sostituire OmicsPortalAdapter con un altro adapter della stessa interfaccia, so that un nuovo portale non richieda modifiche alle pagine o al workflow.
29. As an utente il cui accesso scade, I want che lo stato protetto venga chiuso e il rientro segua il contenitore, so that non compaia un login ThothII in embedded.
30. As an operatore, I want documentazione accurata di configurazione, autenticazione, adapter, migrazione e verifiche, so that il deploy server sia ripetibile.
## Implementation Decisions
- La configurazione installata distingue topologia, autenticazione e shell. Shell omessa significa embedded con adapter Omics predefinito; adapter sconosciuti sono errori espliciti. Full non istanzia adapter.
- La configurazione frontend pubblica contiene soltanto dati non segreti e usa il meccanismo runtime esistente, compreso il prefisso API necessario al montaggio Omics.
- Un controller di shell espone preferenze e stato; i componenti non accedono direttamente al portale.
- L'interfaccia PortalAdapter offre una sottoscrizione con snapshot iniziale, aggiornamenti, errori e disiscrizione. Lo snapshot contiene locale, tema light/dark e fullscreen.
- L'implementazione Omics legge la lingua effettivamente renderizzata dal selettore, osserva il tema sul documento e ascolta il fullscreen del browser. Non introduce handshake, eventi personalizzati o polling per le preferenze. Il contratto DOM è privato dell'adapter.
- Il montaggio resta nello stesso documento. Nessun header o comando locale di autenticazione/presentazione viene introdotto in embedded.
- Il server resta autorevole per identità e autorizzazioni. Il bridge UI non trasmette token, utente o flag authenticated. La catena di identità fidata esistente viene conservata.
- I rifiuti di accesso all'applicazione devono essere distinti dai 403 relativi a una singola operazione. Ricontrollare l'accesso alla riconnessione e al ritorno alla pagina; non promettere revoca istantanea di altre schede tramite il solo proxy.
- Full riutilizza login e logout esistenti, preserva le protezioni dalle modifiche non salvate e abilita il logout anche per sessioni ThothII OIDC. Il logout globale dall'identity provider non è implicito.
- Fullscreen è indipendente dalla modalità full. L'icona segue lo stato effettivo e le richieste rifiutate sono gestite. I token dark esistenti vengono completati, con verifica dei contenuti sovrapposti e dell'isolamento degli stili embedded.
- L'i18n utilizza cataloghi estendibili EN/IT con fallback inglese, comprese label, aiuti, placeholder, accessibilità, errori e widget deterministici. I payload tecnici restano stabili.
- La lingua di interazione viene scelta dal locale UI risolto, salvata nel manifest e propagata al contesto del modello. Resume non accetta override dal browser.
- Le sessioni precedenti prive del campo usano la lingua del workspace come compatibilità; il valore viene fissato alla prima ripresa mediante aggiornamento idempotente. Non si pretende di ricostruire una lingua storica non registrata.
- Il cambio lingua Omics mantiene la navigazione Django. La selezione della sessione deve sopravvivere alla navigazione; le modifiche non salvate devono essere protette, senza avviare una nuova generazione implicitamente.
## Testing Decisions
I punti di verifica erano già approvati nel piano: comportamento della shell e
dell'accesso dall'interfaccia, contratto pubblico dell'adapter, creazione/ripresa
tramite API e CLI del workflow, configurazione d'installazione e integrazione
Omics. Non si richiede una nuova approvazione degli stessi punti.
- Test comportamentali: stato osservabile, testo e controlli accessibili, permessi e lingua persistita; evitare metodi privati o asserzioni sull'organizzazione interna.
- Riutilizzare i test AuthGate/AppShell con API simulate al confine HTTP, quelli delle route sessioni e quelli pubblici del repository/CLI del workflow.
- Verificare adapter con un documento equivalente al template reale, preferenze iniziali, aggiornamenti e cleanup; includere montaggio ripetuto.
- Verificare nuova sessione, resume con lingua UI diversa, manifest precedente e input locale invalido; il contesto fornito al modello deve contenere la lingua persistita.
- Verificare fullscreen con ingresso, uscita, Esc e rifiuto; entrambe le modalità con dark, form e menu aperti.
- Verificare accesso embedded senza secondo login, scadenza/403, riconnessione degli eventi e ritorno a una scheda; verificare logout full e protezione dei dati di un utente precedente.
- Typecheck e test mirati durante lo sviluppo; suite complete alla fine, build documentale e prova browser proporzionata. Non usare chiamate reali al modello per i test deterministici.
## Out of Scope
- Deploy sul server di produzione o modifica delle credenziali.
- Seconda implementazione per un portale futuro, iframe e protocollo postMessage.
- Nuovo sistema di autenticazione, propagazione di token nel browser o logout globale OIDC.
- Tema system, ingresso automatico in fullscreen, rotellina amministrativa nell'header full.
- Traduzione di SQL, dati, identificatori o contenuti del workspace; traduzione a posteriori delle decisioni generate dal modello.
- Persistenza di una trascrizione integrale delle conversazioni.
## Further Notes
La revisione della semplificazione del 2026-09-13 è stata approvata dall'utente e
prevale sui dettagli superati del primo contratto a eventi. La specifica consolida
le decisioni senza riaprire l'intervista. Le modifiche vengono revisionate sui due
assi Standards/Spec e committate sul branch corrente, preservando i cambiamenti
preesistenti estranei a questa funzionalità.
@@ -0,0 +1,129 @@
# Full shell, Omics e interfaccia bilingue — verifica
Data: 2026-09-13. Specifica: [Full shell, integrazione Omics e interfaccia bilingue](../plans/2026-09-13-full-shell-spec.md).
I cinque ticket e le loro dipendenze sono elencati nel [piano](../plans/2026-09-13-full-shell-and-portal-integration.md).
## Risultato
- Configurazione pubblica generata dal descrittore installato; full ed embedded
usano la stessa immagine frontend. Sul Mac: `full`, locale iniziale `en`.
- Header full con lingua, tema, fullscreen reale e menu utente/logout; rail sinistro
vuoto di almeno 20 px. Nessuna rotellina amministrativa.
- Embedded senza header locale; `OmicsPortalAdapter` incapsula l'osservazione del
portale. Identità e autorizzazioni rimangono verificate dal server.
- Cataloghi EN/IT, traduzioni dei controlli delle griglie, grafici e SQL adattati
al tema. I contenuti di dominio rimangono invariati.
- `interaction_language` fissata nel manifest: cattura alla creazione, mantenimento
alla ripresa e assegnazione atomica del valore workspace per sessioni precedenti.
- Continuità della selezione al reload, nessuna ripresa automatica del modello e
protezione delle bozze non inviate.
La [guida operativa](../operations/shell-and-localization.md) descrive configurazione,
accesso, estensione ad altri portali/lingue e checklist di deploy.
## Verifiche automatiche
| Livello | Esito |
| --- | --- |
| CLI nativa Go | `go test ./...`: 21 package verificati |
| Harness Python | 1290 passati, 1 saltato, 6 L2 esclusi |
| Gate Pi JavaScript | 205 passati |
| Frontend | suite completa: 755 passati in 90 file; regressione CSS prebuild superata |
| Backend | suite completa Node 24: 1397 passati, 40 saltati |
| Omics Docker isolato | 15 test Django/integrazione passati; controlli JavaScript del browser inclusi |
| Cataloghi | 1666 messaggi italiani, 1701 riferimenti statici; conflitti e interpolazioni verificati |
| Build | frontend, backend/typecheck e documentazione MkDocs strict verificati; regressione CSS eseguita automaticamente prima della build frontend |
La suite backend richiede Node 24. Sul Mac il percorso Homebrew `node@24`
risolveva a Node 25; è stato usato esplicitamente Node 24.16.0 già presente in NVM,
senza modificare il runtime globale. Un test preesistente sul timeout di arresto
di un processo è fallito sotto carico parallelo ed è passato isolatamente;
la verifica finale usa un solo worker.
I test coprono comportamenti pubblici e confini approvati: manifest filesystem e
PostgreSQL, CLI, Fastify, processo Pi simulato, stream SSE, shell e widget React,
template/autorizzazione Omics. Non è stata avviata una nuova generazione L2 contro
il modello remoto o il DWH operativo. Il controllo statico dei cataloghi non è
una prova di traduzione di ogni possibile messaggio diagnostico esterno.
## Standards
Revisione indipendente rispetto al punto iniziale ThothII
`2d1b714ebe31419d712e9c3324a5e171d5f0317d` e Omics `aff7581`.
Due violazioni concrete del contratto: bozze dei gate non protette dal reload e
selezione di sessioni altrui persa per gli amministratori. Entrambe corrette e
riesaminate dal revisore indipendente. La protezione resta attiva anche durante
un invio e dopo un errore HTTP, fino all'accettazione e allo smontaggio del widget.
Nessun ulteriore rilievo concreto sul codice Omics o sulle euristiche di manutenibilità.
Esito finale del revisore: approvato, zero rilievi aperti.
## Spec
Quattro rilievi: i due precedenti, più verifica dell'identità mancante dopo una
riconnessione SSE riuscita ed errori deterministici dello stream non tradotti.
Le correzioni includono test di regressione e sono state riesaminate dal revisore
indipendente. Sono tradotti anche gli errori sanitizzati di avvio e le notifiche
deterministiche dei gate già localizzate nella lingua fissata della sessione.
Esito finale del revisore: tutti e quattro i rilievi chiusi, nessun nuovo rilievo
o ampliamento ingiustificato dell'ambito.
Riepilogo iniziale: Standards 2 rilievi P2; Spec 4 rilievi P2. Gli assi restano
separati; i rilievi comuni non rappresentano ulteriori difetti distinti.
Riepilogo finale: Standards 0 aperti; Spec 0 aperti.
## Collaudo visivo
Il collaudo con l'account locale autenticato ha trovato tre difetti non visibili
nei test DOM dei componenti:
1. Il contenitore CSS `@scope` includeva l'intero livello base Tailwind e nel browser
integrato non applicava i token. Il CSS era servito con HTTP 200, ma il font
effettivo era Times e `--background` risultava vuoto. L'isolamento del reset
è ora compilato in selettori ordinari, senza modificare gli stili del portale.
Il browser applica Manrope e i token light/dark corretti. Revisione indipendente
del CSS compilato: nessuna perdita di isolamento individuata.
2. La classe full costruita per interpolazione faceva eliminare il token del
margine dalla build Tailwind. Nomi di classe letterali e una regressione sulla
presenza di `--thot-shell-gutter` rendono il requisito verificabile nella build.
3. Le griglie amministrative caricavano il tema CSS preesistente insieme al nuovo
tema automatico della libreria. Griglie amministrative e anteprime ora usano
un solo sistema CSS con i token della shell, eliminando il conflitto. I token
vengono applicati anche ai contenitori di tema interni creati da AG Grid,
che altrimenti sovrascriverebbero i colori ereditati.
Questi controlli sono stati eseguiti prima del commit di consegna. Il nuovo test
`scripts/scoped-base.test.mjs` viene eseguito dal comando `prebuild` anche in Docker.
Le prove di lingua/tema non hanno modificato dati del catalogo o avviato sessioni.
## Installazione locale e recupero
Il binario nativo aggiornato è installato in `/usr/local/bin/tht`.
Il descrittore locale, non versionato, è
`deploy/psd/thothii-installation.yaml`; `installation generate` ha prodotto:
```javascript
window.__THOTHII_CONFIG__ = {"backendBaseUrl":"/api","shell":{"mode":"full","defaultLocale":"en"}};
```
Il progetto Docker locale è `thothii-18998cca7b0a`; l'aggiornamento riguarda soltanto
core/frontend. Database, Qdrant, embedding e volumi persistenti restano invariati.
Le immagini precedenti sono conservate come
`thothii-core:before-full-shell-20260913` e
`thothii-frontend:before-full-shell-20260913`.
Binario e descrittore precedenti sono in `/private/tmp/thothii-shell-install.8idNoC`
(copia temporanea locale, non backup permanente).
Il sorgente Omics aggiornato è registrato nel commit locale `95154e1`.
Nessun push o deploy sul server di produzione è stato eseguito. Il collaudo
operativo sul server resta una fase del normale rilascio, seguendo la guida.
Core/frontend locali aggiornati e healthy; `/api/health` restituisce 200,
`/config.js` espone full/en e `/api/auth/config` conferma l'accesso locale.
Con l'account locale autenticato sono stati controllati header, cambio EN/IT,
light/dark, menu utente/logout, ingresso/uscita fullscreen tramite pulsante e
margini effettivi di 24 px. I test automatici coprono logout e sincronizzazione
con `fullscreenchange`, compreso il ritorno allo stato normale. L'uscita tramite
tasto Esc nativo resta da provare in un browser desktop ordinario: il comando
di tastiera automatizzato del browser integrato non l'ha riprodotta. Nessuna
promessa di collaudo completo del browser del server è implicita in queste prove.
Le preferenze locali sono state riportate a inglese e tema chiaro.
@@ -0,0 +1,195 @@
# Revisione della semplificazione della shell
Data: 2026-09-13. Oggetto: piano full/embedded, ADR 0021–0022 e contratto
Portal Shell Adapter v1. Questa è una revisione con raccomandazioni: non modifica il
codice applicativo né sostituisce automaticamente i contratti accettati.
## Valutazione
La separazione tra shell, autenticazione e lingua delle sessioni è corretta. La
semplificazione precedente ha però mantenuto un protocollo di comunicazione non
necessario per il montaggio corrente e ha lasciato ambigue alcune condizioni di
compatibilità. Raccomando un adapter che osservi il documento già condiviso con
Omics, riutilizzi l'autenticazione esistente e non imponga un nuovo bridge al portale.
La revisione considera il sorgente locale di ThothII e Omics Portal al commit
`aff7581`, già ottenuto con il pull richiesto. Non certifica quali immagini,
configurazioni o modifiche siano attualmente attive sul server.
## Problemi individuati e correzioni raccomandate
### 1. Il cambio lingua senza ricaricamento non corrisponde a Omics
In `templates/partials/topbar.html:74–87`, Omics usa il form Django `set_language`
con `onchange="this.form.submit()"`. La lingua cambia attraverso una nuova pagina
renderizzata dal server. Il criterio 2 del contratto v1 promette invece aggiornamenti
senza ricaricamento per tutte le preferenze.
Raccomandazione: rispettare il comportamento del portale. In full, lingua e tema
cambiano immediatamente. In embedded, il tema cambia immediatamente e la lingua
segue il normale ricaricamento Omics. Evitare di intercettare il form per cambiare
solo ThothII: lascerebbe header, sidebar e testi Django nella lingua precedente.
Il ricaricamento va verificato durante una sessione attiva e con modifiche non
salvate: deve essere possibile ritrovare la sessione senza avviare una nuova
generazione; eventuali bozze richiedono una protezione dalla navigazione. Lo stato
persistito del workflow non equivale alla conservazione automatica della bozza UI
o del flusso di messaggi in memoria. Questa verifica è necessaria anche mantenendo
il protocollo a eventi del piano precedente.
### 2. Un protocollo ready/state non è necessario nello stesso documento
`templates/kokoro/datamart_builder.html` monta React in `#root`, nello stesso
documento dell'header. L'adapter può ottenere direttamente:
- lingua effettivamente renderizzata: `data-lang` del selettore
`.omics-language-select`;
- tema: attributo `data-bs-theme` sull'elemento `html`;
- fullscreen: stato del documento e relativo evento del browser.
Un'osservazione limitata all'attributo del tema e un listener del fullscreen
coprono gli aggiornamenti attuali. La lingua viene riletta quando Django restituisce
la pagina. Questi selettori e dettagli devono comparire soltanto dentro
`OmicsPortalAdapter`, con test basati sul template reale.
Attenzione: `templates/base.html:3` contiene oggi `lang="en"` fisso; quell'attributo
non è una fonte attendibile per la lingua Omics. Se in futuro il portale espone la
lingua su un attributo dedicato del punto di montaggio, si modifica soltanto l'adapter.
La proposta elimina due eventi personalizzati, la versione del protocollo, il
timeout di avvio e il rischio che il messaggio iniziale parta prima del listener.
L'osservazione va installata prima di consegnare lo snapshot iniziale; la funzione
di disiscrizione rimuove tutte le risorse. L'assenza dei dati Omics attesi produce
un errore di integrazione comprensibile, senza attivare la shell full.
Il costo accettato è una dipendenza esplicita dal piccolo contratto DOM Omics,
confinata nell'adapter. Un secondo portale potrà fornire gli stessi dati usando
un'altra implementazione, anche con un diverso trasporto. Non occorre costruirla ora.
### 3. `authenticated` duplica uno stato che il server già verifica
`kokoro/datamart_catalog_views.py:49` e `nginx/nginx.conf:69` verificano l'accesso
Omics e trasmettono al backend identità e autorizzazioni normalizzate. ThothII
ottiene già l'utente tramite `/me`. Non serve un ulteriore login né un flag nel
documento che dichiari l'utente autenticato.
Il logout Omics attuale è una navigazione (`topbar.html:118`), non un evento di
revoca. Un click sul link non prova che il logout sia stato completato; inoltre,
un flag inviato una volta non rileva la scadenza della sessione o un logout in
un'altra scheda.
Raccomandazione: togliere `authenticated` dallo snapshot visivo. La verifica
dell'accesso rimane nel percorso di autenticazione esistente. In embedded,
perdita dell'accesso significa chiudere i dati protetti e demandare il rientro
al portale, senza mostrare il form di login ThothII.
Serve verificare la gestione dei rifiuti Omics: l'endpoint di autorizzazione
restituisce attualmente 403 anche quando l'accesso non è disponibile, mentre
`frontend/src/api/client.ts` pulisce automaticamente lo stato su 401. Un 403
ordinario può anche significare che manca il permesso per una sola operazione;
non va trasformato indiscriminatamente in logout. La verifica `/me` deve
distinguere la perdita di accesso all'applicazione dal rifiuto di una sua funzione.
`frontend/src/stream/useSessionStream.ts` esegue già un controllo di autenticazione
quando il collegamento eventi fallisce: riutilizzare quel percorso. Non promettere
revoca istantanea di una connessione già aperta in un'altra scheda sulla sola base
di `auth_request` o di un evento nella pagina corrente. Il contratto deve dichiarare
quando l'accesso viene ricontrollato e verificare anche il ritorno a una scheda
rimasta aperta.
In full sul Mac resta il login locale esistente. Il logout backend esiste già
(`backend/src/auth/routes.ts:355`): il lavoro riguarda il collegamento all'header
e la pulizia della UI. Per installazioni full con OIDC va consentito anche quel
logout; oggi `AuthGate` lo espone soltanto per `mode === "local"`. La revoca della
sessione ThothII non va descritta come logout globale dal fornitore d'identità.
### 4. Il default embedded contraddice l'adapter obbligatorio
Il piano dichiara compatibilità con descrittori senza `shell`, ma il contratto
richiede `adapter` in embedded. Inoltre, pretendere un nuovo bridge renderebbe
inutilizzabile la vecchia pagina Omics finché non fosse aggiornata.
Raccomandazione: risolvere i valori in un unico punto di configurazione:
- assenza di `shell`: embedded con adapter Omics predefinito;
- embedded senza `adapter`: `omics-portal`;
- full: nessun adapter istanziato;
- nome adapter sconosciuto: errore esplicito di configurazione.
L'adapter che legge lo stato già esistente rende questa compatibilità concreta.
Sul Mac rimangono espliciti `mode: full` e `defaultLocale: en`. La proiezione
pubblica può usare il `config.js` già presente; non serve un nuovo servizio di
configurazione. Va verificato anche il prefisso API `/datamart-builder/api` nel
montaggio Omics: il default frontend `/api` non basta a dimostrare che il deploy
funzioni. Prima si aggiorna il lettore del descrittore, poi il file installato,
poiché il lettore corrente rifiuta chiavi sconosciute.
### 5. Fullscreen e dark mode hanno già elementi riutilizzabili
ThothII possiede già token scuri in `frontend/src/index.css:67`, attivati anche da
`data-bs-theme="dark"`. Il lavoro è completarne la copertura e verificare contrasto,
form, menu e finestre, riutilizzando questi token.
Omics cambia la classe `fullscreen-enable` al click (`static/js/app.js:702`)
prima di conoscere il risultato. Il ramo di uscita usa metodi storici e non
contiene `document.exitFullscreen()`. Non va copiato nella shell full: l'icona
deve seguire lo stato effettivo, compresi Esc e richieste rifiutate. La correzione
equivalente dell'header Omics appartiene al suo modulo UI, non a un secondo
controllo fullscreen dentro ThothII embedded.
La fascia vuota sinistra si realizza con una misura CSS condivisa, almeno 20 px,
bilanciata con lo spazio destro. Non richiede un modulo di navigazione vuoto.
Poiché il documento è condiviso, verificare anche che stili globali ThothII e
contenuti sovrapposti non alterino header e sidebar del portale: il template Omics
contiene già correzioni per reset CSS e altezza `100vh`.
## Elementi da conservare
La lingua dell'interfaccia, quella delle domande al revisore e quella dei contenuti
del workspace hanno proprietari e durate diverse. Conservare la separazione di
ADR 0022: semplificarla in un'unica preferenza globale introdurrebbe errori in
ripresa e nei workspace italiani.
Precisare tre regole d'implementazione:
- una nuova sessione salva il locale UI effettivamente risolto, dopo il fallback
delle traduzioni;
- una sessione esistente conserva la propria lingua anche se un altro revisore
usa un'interfaccia diversa;
- per manifest precedenti senza campo lingua, usare la lingua del workspace come
criterio di compatibilità e fissarla alla prima ripresa con un aggiornamento
idempotente. Non dedurla dalla lingua del browser del nuovo revisore. La lingua
storica esatta non è ricostruibile se il workspace è stato cambiato nel frattempo.
L'i18n resta il lavoro trasversale principale: include pagine amministrative,
errori, accessibilità e testi deterministici dei widget, anche quelli costruiti
fuori da React. Aggiungere soltanto i cataloghi dell'header non soddisfa la richiesta.
Il modello deve ricevere la lingua dal manifest autorevole, senza tradurre a
posteriori payload delle decisioni, SQL o contenuti del workspace.
## Struttura raccomandata
Un solo controller di shell alimenta l'interfaccia. In full gestisce preferenze
locali e header; in embedded riceve `{ locale, theme, fullscreen }` da un
`PortalAdapter.subscribe(...)`. I componenti applicativi usano quello stato e
non conoscono Omics. Nessun registro dinamico di plugin, protocollo di comandi o
controller separato per ciascun pulsante.
L'autenticazione continua a usare il modulo esistente, con presentazione dell'accesso
coerente con la shell. L'integrazione con un futuro portale richiede anche che il
suo lato server soddisfi il contratto di identità verificata: sostituire una classe
JavaScript non può da solo sostituire l'autenticazione server. Documentare insieme
l'adapter UI e la configurazione server Omics, senza introdurre nuove dipendenze
Omics nel workflow o nelle pagine ThothII.
## Verifiche necessarie prima della consegna
Verificare full sul Mac con default inglese, accesso/logout, tema e fullscreen;
embedded sul template Omics, con preferenze già impostate prima del montaggio,
cambio tema e lingua, Esc, assenza di header ThothII e descrittore precedente.
Verificare nuova sessione, ripresa, manifest precedente, ricaricamento durante
la revisione e perdita dell'accesso con collegamento eventi attivo.
Sono verifiche del comportamento, non motivi per costruire un'infrastruttura
generica. Questa revisione si basa sull'ispezione del sorgente; non sono stati
eseguiti test runtime né modificati i due applicativi.
+7
View File
@@ -6,6 +6,7 @@
"": {
"name": "thothii-frontend",
"dependencies": {
"@ag-grid-community/locale": "36.0.0",
"@base-ui/react": "^1.6.0",
"@fontsource-variable/geist": "^5.2.9",
"@tanstack/react-query": "^5.59.0",
@@ -52,6 +53,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@ag-grid-community/locale": {
"version": "36.0.0",
"resolved": "https://registry.npmjs.org/@ag-grid-community/locale/-/locale-36.0.0.tgz",
"integrity": "sha512-zMgk4xrTvAHRVr9V1DHi4pEO56bdXxqDfTD6qZOebaP7pL9KZ4Pioc7HZu4dV3FHAYWct9seg6AsbP9rCiQIPQ==",
"license": "MIT"
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+3
View File
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"prebuild": "node --test scripts/scoped-base.test.mjs",
"dev": "vite",
"prototype:database-management": "vite --config vite.database-management-prototype.config.ts",
"prototype:administration-pages": "vite --config vite.administration-pages-prototype.config.ts",
@@ -13,9 +14,11 @@
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"check:i18n": "node scripts/check-i18n.mjs",
"e2e": "playwright test"
},
"dependencies": {
"@ag-grid-community/locale": "36.0.0",
"@base-ui/react": "^1.6.0",
"@fontsource-variable/geist": "^5.2.9",
"@tanstack/react-query": "^5.59.0",
+5 -6
View File
@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
import tailwindcss from "tailwindcss";
import autoprefixer from "autoprefixer";
import scopedBase from "./scripts/scoped-base.mjs";
export default { plugins: [tailwindcss(), autoprefixer(), scopedBase()] };
+56
View File
@@ -0,0 +1,56 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const root = fileURLToPath(new URL("../src/", import.meta.url));
const files = (directory) => fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory() ? files(path.join(directory, entry.name)) : [path.join(directory, entry.name)],
);
const source = (file) => ts.createSourceFile(file, fs.readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const catalog = new Map();
const problems = [];
const slots = (value) => [...value.matchAll(/\{([A-Za-z][A-Za-z0-9_]*)\}/g)].map((match) => match[1]).sort().join(",");
for (const file of files(path.join(root, "i18n/locales"))) {
if (!/it.*\.ts$/.test(file)) continue;
function visit(node) {
if (ts.isPropertyAssignment(node) && ts.isStringLiteral(node.name) && ts.isStringLiteral(node.initializer)) {
const key = node.name.text;
const value = node.initializer.text;
if (slots(key) !== slots(value)) problems.push(`${path.relative(root, file)}: interpolation mismatch for ${key}`);
// Shared gettext messages may appear in multiple feature catalogues.
// Their meaning and translation must agree regardless of merge order.
if (catalog.has(key) && catalog.get(key) !== value) problems.push(`${path.relative(root, file)}: conflicting translation for ${key}`);
catalog.set(key, value);
}
ts.forEachChild(node, visit);
}
visit(source(file));
}
let references = 0;
for (const file of files(root)) {
if (!/\.tsx?$/.test(file) || /(?:\.test\.|\/test\/|\/i18n\/|prototype)/i.test(file)) continue;
const tree = source(file);
function visit(node) {
if (ts.isCallExpression(node) && /^(t|translate)$/.test(node.expression.getText(tree))) {
const arg = node.arguments[0];
if (arg && ts.isStringLiteral(arg)) {
references++;
if (!catalog.has(arg.text)) {
const line = tree.getLineAndCharacterOfPosition(arg.getStart(tree)).line + 1;
problems.push(`${path.relative(root, file)}:${line}: missing Italian message ${JSON.stringify(arg.text)}`);
}
}
}
ts.forEachChild(node, visit);
}
visit(tree);
}
console.log(`${catalog.size} Italian messages; ${references} static translation references checked.`);
if (problems.length) {
console.error(problems.join("\n"));
process.exitCode = 1;
}
+27
View File
@@ -0,0 +1,27 @@
import postcss from "postcss";
const mount = ":where(#root, [data-base-ui-portal])";
/** Compile our Tailwind base into ordinary selectors, without native @scope. */
export default function scopedBase() {
return {
postcssPlugin: "thoth-scoped-base",
OnceExit(root) {
root.walkAtRules("thoth-base", boundary => {
boundary.walkRules(rule => {
// Keyframe selectors are animation offsets, not document selectors.
if (rule.parent.type === "atrule" && /keyframes$/.test(rule.parent.name)) return;
rule.selector = postcss.list.comma(rule.selector).map(selector => {
// Authored tokens already target the mount, including host dark ancestors.
if (selector.includes("#root") || selector.includes("[data-base-ui-portal]")) return selector;
if (["html", ":host", "body", ":root"].includes(selector)) return mount;
if (selector === "*") return `${mount}, ${mount} *`;
if (/^::?(?:before|after|backdrop)$/.test(selector)) return `${mount}${selector}, ${mount} ${selector}`;
return `${mount} ${selector}`;
}).join(", ");
});
boundary.replaceWith(...boundary.nodes);
});
},
};
}
+35
View File
@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import postcss from "postcss";
import tailwindcss from "tailwindcss";
test("compiled application tokens are usable without native CSS scope support", async () => {
const source = await readFile(new URL("../src/index.css", import.meta.url), "utf8");
const config = (await import("../postcss.config.js")).default;
const plugins = Array.isArray(config.plugins) ? config.plugins : [tailwindcss()];
const result = await postcss(plugins).process(source, { from: "src/index.css" });
let tokens = 0;
result.root.walkDecls("--background", declaration => {
tokens++;
let parent = declaration.parent;
while (parent.type !== "root") {
assert.notEqual(parent.name, "scope", "native @scope hides the theme on unsupported browsers");
assert.notEqual(parent.name, "thoth-base", "the build must expand the scoped reset");
parent = parent.parent;
}
});
assert.ok(tokens >= 2, "light and dark root tokens must be emitted");
let gutter = false;
result.root.walkDecls("--thot-shell-gutter", () => { gutter = true; });
assert.ok(gutter, "full shell gutter must survive Tailwind content pruning");
let nestedGridTheme = false;
result.root.walkDecls("--ag-background-color", declaration => {
const selector = declaration.parent.selector;
if (selector.includes(".thot-database-grid") && selector.includes(".ag-theme-alpine")) nestedGridTheme = true;
});
assert.ok(nestedGridTheme, "AG Grid nested theme roots must receive shell colors directly");
result.root.walkDecls("box-sizing", declaration => {
assert.match(declaration.parent.selector, /#root|data-base-ui-portal/, "reset must not change the host portal");
});
});
+17 -2
View File
@@ -1,9 +1,17 @@
import { render, screen } from "@testing-library/react";
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "./test/msw";
import { App } from "./App";
beforeEach(() => { window.__THOTHII_CONFIG__ = { shell: { mode: "full", defaultLocale: "en" } }; });
afterEach(() => {
cleanup();
delete window.__THOTHII_CONFIG__;
document.querySelector(".omics-language-select")?.remove();
document.documentElement.removeAttribute("data-bs-theme");
});
function registerAuthenticatedShell(mode: "local" | "upstream") {
server.use(
http.get("/api/auth/config", () => HttpResponse.json({
@@ -40,10 +48,17 @@ test("local authentication renders the standalone logout control", async () => {
expect(await screen.findByRole("button", { name: "New session" })).toBeInTheDocument();
expect(screen.getByText("Portal user")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument();
await userEvent.click(within(screen.getByRole("banner", { name: "Application header" })).getByRole("button", { name: "Portal user" }));
expect(await screen.findByRole("menuitem", { name: "Log out" })).toBeInTheDocument();
});
test("trusted upstream authentication keeps identity but omits ThothII logout", async () => {
delete window.__THOTHII_CONFIG__;
const select = document.createElement("select");
select.className = "omics-language-select";
select.dataset.lang = "en";
document.body.append(select);
document.documentElement.setAttribute("data-bs-theme", "light");
registerAuthenticatedShell("upstream");
render(<App />);
+2 -1
View File
@@ -1,11 +1,12 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./app/queryClient";
import { AuthGate } from "./auth/AuthGate";
import { ShellProvider } from "./shell/host/ShellProvider";
export function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthGate />
<ShellProvider><AuthGate /></ShellProvider>
</QueryClientProvider>
);
}
+18 -1
View File
@@ -1,6 +1,23 @@
import { describe, expect, it } from "vitest";
import { backendBaseUrl, joinBackendPath, resolveBackendUrl } from "./runtime-config";
import { backendBaseUrl, joinBackendPath, resolveBackendUrl, resolveShellConfig, type RuntimeConfig } from "./runtime-config";
describe("resolveShellConfig", () => {
it("preserves legacy embedded Omics configuration", () => {
expect(resolveShellConfig({})).toEqual({ mode: "embedded", defaultLocale: "en", adapter: "omics-portal" });
expect(resolveShellConfig({ shell: { mode: "embedded", defaultLocale: "it" } }).adapter).toBe("omics-portal");
});
it("full does not use an adapter", () => {
expect(resolveShellConfig({ shell: { mode: "full", defaultLocale: "en", adapter: "omics-portal" } })).toEqual({ mode: "full", defaultLocale: "en" });
});
it.each([
{ mode: "other", defaultLocale: "en" },
{ mode: "full", defaultLocale: "" },
{ mode: "embedded", defaultLocale: "en", adapter: "unknown" },
])("rejects invalid shell configuration %j", shell => {
expect(() => resolveShellConfig({ shell } as RuntimeConfig)).toThrow();
});
});
describe("resolveBackendUrl", () => {
it("uses same-origin /api by default", () => {
+24
View File
@@ -1,5 +1,29 @@
export interface RuntimeConfig {
backendBaseUrl?: string;
shell?: ShellConfig;
}
export interface ShellConfig {
mode: "full" | "embedded";
defaultLocale: string;
adapter?: "omics-portal";
}
export function resolveShellConfig(config: RuntimeConfig | undefined = window.__THOTHII_CONFIG__): ShellConfig {
const shell = config?.shell;
if (shell === undefined) return { mode: "embedded", defaultLocale: "en", adapter: "omics-portal" };
if (!shell || (shell.mode !== "full" && shell.mode !== "embedded")) {
throw new Error("Invalid shell mode. Use full or embedded.");
}
if (typeof shell.defaultLocale !== "string" || !shell.defaultLocale.trim()) {
throw new Error("A default interface language is required.");
}
if (shell.adapter !== undefined && shell.adapter !== "omics-portal") {
throw new Error("Unknown portal adapter. Check the installation configuration.");
}
return shell.mode === "full"
? { mode: "full", defaultLocale: shell.defaultLocale }
: { ...shell, adapter: "omics-portal" };
}
declare global {
+27
View File
@@ -7,6 +7,30 @@ import {
deleteSession, getSessionDocuments,
} from "./sessions";
import { workspacePreferences } from "../workspaces/preferences";
import { setLocale } from "../i18n";
afterEach(() => setLocale("en"));
test("new sessions capture the selected interaction language before asynchronous preparation", async () => {
workspacePreferences.save({
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
});
let body: Record<string, unknown> | undefined;
server.use(
http.get("/api/workspaces", () => HttpResponse.json([
workspaceSummaryFixture("psd-clinical", { revision: workspaceRevisionFixture("psd-clinical") }),
])),
http.post("/api/sessions", async ({ request }) => {
body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ id: "italian-session" });
}),
);
setLocale("it-IT");
const creation = createSession({ question: "Pazienti con fibrillazione" });
setLocale("en");
await creation;
expect(body?.interactionLanguage).toBe("it");
});
test("createSession seeds ephemeral selections and posts them", async () => {
localStorage.clear();
@@ -33,6 +57,7 @@ test("createSession seeds ephemeral selections and posts them", async () => {
expect(await createSession({ question: "q" })).toEqual({ id: "s1" });
expect(body).toEqual({
question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
interactionLanguage: "en",
});
expect(workspacePreferences.load()).toEqual({
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
@@ -138,6 +163,7 @@ test("createSession replaces a stale ephemeral workspace with the current instal
expect(body).toEqual({
question: "q", workspaceId: "psd-clinical",
provider: "zai", model: "glm-5.2", thinking: "low",
interactionLanguage: "en",
});
expect(workspacePreferences.load()).toEqual({
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
@@ -161,6 +187,7 @@ test("createSession preserves a local legacy selection when the registry is empt
expect(body).toEqual({
question: "q", workspaceId: "legacy-workspace",
provider: "zai", model: "glm-5.2", thinking: "low",
interactionLanguage: "en",
});
});
+3 -1
View File
@@ -1,4 +1,5 @@
import { apiFetch } from "./client";
import { getLocale } from "../i18n";
import {
captureAuthOperation,
isAuthOperationCurrent,
@@ -93,6 +94,7 @@ async function ensureWorkspaceSelectionPolicy(precondition?: AuthOperationPrecon
}
export async function createSession(i: NewSessionInput, precondition?: AuthOperationPrecondition) {
const interactionLanguage = getLocale();
const initiatingOperation = captureAuthOperation();
const effectivePrecondition = precondition ?? (initiatingOperation ? {
operation: initiatingOperation,
@@ -108,7 +110,7 @@ export async function createSession(i: NewSessionInput, precondition?: AuthOpera
requireAuthOperationPrecondition(effectivePrecondition);
return apiFetch<{ id: string }>("/sessions", {
method: "POST",
body: JSON.stringify({ ...i, ...selection }),
body: JSON.stringify({ ...i, ...selection, interactionLanguage }),
});
}
+2
View File
@@ -1,6 +1,8 @@
export interface WidgetOption {
id: string;
label: string;
/** Present only for harness-authored deterministic chrome; model prose has no key. */
label_i18n?: string;
detail?: string;
rationale?: string;
meta?: Record<string, unknown>;
+64 -5
View File
@@ -1,4 +1,4 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse, delay } from "msw";
import { beforeEach, afterEach, describe, expect, test, vi } from "vitest";
@@ -6,12 +6,16 @@ import { StrictMode } from "react";
import { AuthGate } from "./AuthGate";
import { clearAuthState, getAuthGeneration, getAuthState, setAuthState } from "./authState";
import { server } from "../test/msw";
import { apiFetch } from "../api/client";
import { queryClient } from "../app/queryClient";
import { useSessionStore } from "../store/sessionStore";
import { checkAccess } from "./checkAccess";
vi.mock("../shell/AppShell", () => ({
AppShell: () => (
AppShell: ({ canLogout }: { canLogout: boolean }) => (
<div data-testid="authenticated-shell">
Authenticated shell
<button type="button" onClick={() => { window.dispatchEvent(new Event("test-logout")); }}>Log out</button>
{canLogout && <button type="button" onClick={() => { window.dispatchEvent(new Event("test-logout")); }}>Log out</button>}
</div>
),
}));
@@ -35,6 +39,7 @@ const user = {
const localConfig = { mode: "local", localLogin: true, oidcLogin: false };
beforeEach(() => {
window.__THOTHII_CONFIG__ = { shell: { mode: "full", defaultLocale: "en" } };
clearAuthState();
server.use(
http.get("/api/auth/config", () => HttpResponse.json(localConfig)),
@@ -45,9 +50,63 @@ beforeEach(() => {
afterEach(() => {
cleanup();
clearAuthState();
delete window.__THOTHII_CONFIG__;
});
describe("AuthGate", () => {
test("embedded uses only /me and never offers a second login", async () => {
delete window.__THOTHII_CONFIG__;
const configRequest = vi.fn();
server.use(http.get("/api/auth/config", () => { configRequest(); return HttpResponse.json(localConfig); }));
const view = render(<AuthGate />);
await screen.findByTestId("authenticated-shell");
expect(screen.queryByRole("button", { name: "Log out" })).not.toBeInTheDocument();
expect(configRequest).not.toHaveBeenCalled();
view.unmount();
server.use(http.get("/api/me", () => new HttpResponse(null, { status: 401 })));
render(<AuthGate />);
await screen.findByRole("heading", { name: "Portal access required" });
expect(screen.queryByLabelText("Password")).not.toBeInTheDocument();
});
test("full OIDC exposes the existing session logout", async () => {
server.use(http.get("/api/auth/config", () => HttpResponse.json({ mode: "oidc", localLogin: false, oidcLogin: true })));
render(<AuthGate />);
expect(await screen.findByRole("button", { name: "Log out" })).toBeInTheDocument();
});
test("an operation 403 retains access, while a page-return /me 403 scrubs protected data", async () => {
delete window.__THOTHII_CONFIG__;
server.use(http.get("/api/denied-operation", () => new HttpResponse(null, { status: 403 })));
render(<AuthGate />);
await screen.findByTestId("authenticated-shell");
queryClient.setQueryData(["protected"], { secret: "previous user data" });
useSessionStore.getState().applyEvent({ type: "text_delta", text: "Private transcript" });
await expect(apiFetch("/denied-operation")).rejects.toMatchObject({ status: 403 });
expect(getAuthState()).not.toBeNull();
expect(screen.getByTestId("authenticated-shell")).toBeInTheDocument();
server.use(http.get("/api/me", () => new HttpResponse(null, { status: 403 })));
act(() => { window.dispatchEvent(new Event("focus")); });
await waitFor(() => expect(screen.queryByTestId("authenticated-shell")).not.toBeInTheDocument());
expect(queryClient.getQueryData(["protected"])).toBeUndefined();
expect(useSessionStore.getState().transcript).toEqual([]);
expect(screen.queryByLabelText("Password")).not.toBeInTheDocument();
});
test("the reconnect probe revokes /me denial and preserves work when only expiry changes", async () => {
setAuthState(user);
const generation = getAuthGeneration();
queryClient.setQueryData(["protected"], "kept");
server.use(http.get("/api/me", () => HttpResponse.json({ ...user, session: { ...user.session, idleExpiresAt: "2026-09-13T12:00:00.000Z" } })));
await checkAccess();
expect(getAuthGeneration()).toBe(generation);
expect(queryClient.getQueryData(["protected"])).toBe("kept");
server.use(http.get("/api/me", () => new HttpResponse(null, { status: 403 })));
await expect(checkAccess()).rejects.toMatchObject({ status: 403 });
expect(getAuthState()).toBeNull();
expect(queryClient.getQueryData(["protected"])).toBeUndefined();
});
test("shows a loading state while /me is unresolved", async () => {
server.use(http.get("/api/me", async () => {
await delay(100);
@@ -88,8 +147,8 @@ describe("AuthGate", () => {
expect(await screen.findByRole("heading", { name: /access not permitted/i })).toBeInTheDocument();
expect(screen.getByText(/signed in without permission/i)).toBeInTheDocument();
expect(getAuthState()).toMatchObject({ subject: "user-1", csrfToken: "c".repeat(43) });
expect(getAuthGeneration()).toBe(generation);
expect(getAuthState()).toBeNull();
expect(getAuthGeneration()).toBeGreaterThan(generation);
});
test("offers retry when the authentication provider is unavailable", async () => {
+81 -86
View File
@@ -1,122 +1,117 @@
import { useCallback, useEffect, useState } from "react";
import { ApiError } from "../api/client";
import { authErrorStatus, getAuthConfig, getMe } from "../api/auth";
import type { AuthenticatedUser, AuthPublicConfig } from "../api/types";
import type { AuthPublicConfig } from "../api/types";
import { resolveShellConfig } from "../api/runtime-config";
import { AppShell } from "../shell/AppShell";
import { FullHeader } from "../shell/host/FullHeader";
import { useShell } from "../shell/host/ShellProvider";
import {
clearAuthStateIfCurrent,
getAuthGeneration,
getAuthState,
isAuthGenerationCurrent,
setAuthState,
useAuthGeneration,
useAuthUser,
clearAuthStateIfCurrent, getAuthGeneration, getAuthState,
isAuthGenerationCurrent, setAuthState, useAuthGeneration, useAuthUser,
} from "./authState";
import { checkAccess } from "./checkAccess";
import { LoginPage } from "./LoginPage";
import { Button } from "../components/ui/button";
import { useI18n } from "../i18n";
type GateStatus = "loading" | "login" | "authenticated" | "forbidden" | "unavailable";
function AuthenticatedContent({
canLogout,
onExpired,
}: {
canLogout: boolean;
onExpired: () => void;
}) {
function AuthenticatedContent({ canLogout, onExpired }: { canLogout: boolean; onExpired: () => void }) {
const user = useAuthUser();
const authGeneration = useAuthGeneration();
useEffect(() => {
if (!user) onExpired();
}, [onExpired, user]);
return user
? <AppShell key={`${user.issuer}:${user.subject}:${authGeneration}`} canLogout={canLogout} />
: null;
useEffect(() => { if (!user) onExpired(); }, [onExpired, user]);
return user ? <AppShell key={`${user.issuer}:${user.subject}:${authGeneration}`} canLogout={canLogout} /> : null;
}
export function AuthGate() {
const { t } = useI18n();
const shell = useShell();
const embedded = (shell?.mode ?? resolveShellConfig().mode) === "embedded";
const [status, setStatus] = useState<GateStatus>("loading");
const [config, setConfig] = useState<AuthPublicConfig>();
const [attempt, setAttempt] = useState(0);
const retry = useCallback(() => setAttempt((value) => value + 1), []);
const retry = useCallback(() => setAttempt(value => value + 1), []);
const onExpired = useCallback(() => setStatus("login"), []);
useEffect(() => {
let cancelled = false;
const load = async () => {
setStatus("loading");
const loadGeneration = getAuthGeneration();
const generation = getAuthGeneration();
try {
const publicConfig = await getAuthConfig();
if (cancelled || !isAuthGenerationCurrent(loadGeneration)) return;
setConfig(publicConfig);
try {
const authenticated = await getMe();
if (cancelled || !isAuthGenerationCurrent(loadGeneration)) return;
setAuthState(authenticated);
setStatus("authenticated");
} catch (error) {
if (cancelled) return;
const statusCode = authErrorStatus(error);
if (statusCode === 401) {
if (isAuthGenerationCurrent(loadGeneration)) clearAuthStateIfCurrent(loadGeneration);
if (getAuthState() === null) setStatus("login");
return;
}
if (!isAuthGenerationCurrent(loadGeneration)) return;
if (statusCode === 403) setStatus("forbidden");
else if (statusCode === 503) setStatus("unavailable");
else setStatus("login");
// Embedded authentication is already established by the portal.
if (!embedded) {
const publicConfig = await getAuthConfig();
if (cancelled || !isAuthGenerationCurrent(generation)) return;
setConfig(publicConfig);
}
const user = await getMe();
if (cancelled || !isAuthGenerationCurrent(generation)) return;
setAuthState(user);
setStatus("authenticated");
} catch (error) {
if (cancelled || !isAuthGenerationCurrent(loadGeneration)) return;
if (error instanceof ApiError && error.status === 503) setStatus("unavailable");
else setStatus("unavailable");
if (cancelled) return;
const code = authErrorStatus(error);
if (code === 401 || code === 403) {
clearAuthStateIfCurrent(generation);
if (!getAuthState()) setStatus(code === 403 ? "forbidden" : "login");
} else if (isAuthGenerationCurrent(generation)) setStatus("unavailable");
}
};
void load();
return () => { cancelled = true; };
}, [attempt]);
}, [attempt, embedded]);
if (status === "loading") {
return <main className="grid min-h-screen place-items-center bg-background" role="status" aria-label="Checking access"><p className="text-sm text-muted-foreground">Checking access…</p></main>;
}
useEffect(() => {
let disposed = false;
let pending = false;
const recheck = async () => {
if (pending || document.visibilityState === "hidden" || status !== "authenticated") return;
pending = true;
try { await checkAccess(); }
catch (error) {
if (!disposed && !getAuthState()) {
const code = authErrorStatus(error);
if (code === 401 || code === 403) setStatus(code === 403 ? "forbidden" : "login");
}
} finally { pending = false; }
};
window.addEventListener("focus", recheck);
window.addEventListener("pageshow", recheck);
document.addEventListener("visibilitychange", recheck);
return () => {
disposed = true;
window.removeEventListener("focus", recheck);
window.removeEventListener("pageshow", recheck);
document.removeEventListener("visibilitychange", recheck);
};
}, [status]);
if (status === "authenticated") {
return (
<AuthenticatedContent
canLogout={config?.mode === "local"}
onExpired={() => setStatus("login")}
/>
);
return <AuthenticatedContent canLogout={!embedded && (config?.mode === "local" || config?.mode === "oidc")} onExpired={onExpired} />;
}
if (status === "unavailable") {
return (
<main className="grid min-h-screen place-items-center bg-background px-5" role="status" aria-label="Authentication unavailable">
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-7 text-center shadow-md">
<p className="thot-label text-primary">ThothII access</p>
<h1 className="mt-3 font-heading text-3xl font-semibold">Authentication unavailable</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">The authentication provider could not be reached. Try again in a moment.</p>
<Button className="mt-6" onClick={retry}>Retry</Button>
if (status === "loading") {
return <main className="thot-access-state" role="status" aria-label={t("Checking access")}><p>{t("Checking access…")}</p></main>;
}
const unavailable = status === "unavailable";
const forbidden = status === "forbidden";
const title = unavailable ? "Authentication unavailable" : forbidden ? "Access not permitted" : "Portal access required";
const description = unavailable
? "The authentication provider could not be reached. Try again in a moment."
: embedded
? "Your portal access is no longer available. Return to the portal to sign in or request access, then reopen ThothII."
: "You are signed in without permission to use this workspace. Contact the installation administrator.";
return <>
{!embedded && <FullHeader />}
{status === "login" && !embedded && config
? <LoginPage config={config} onAuthenticated={() => setStatus("authenticated")} onRetry={retry} />
: <main className="thot-access-state" role="status" aria-label={t(title)}>
<section className="w-full max-w-md text-center">
<p className="thot-label text-primary">{t("ThothII access")}</p>
<h1 className="mt-3 font-heading text-3xl font-semibold">{t(title)}</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">{t(description)}</p>
<Button className="mt-6" onClick={retry}>{t("Retry")}</Button>
</section>
</main>
);
}
if (status === "forbidden") {
return (
<main className="grid min-h-screen place-items-center bg-background px-5">
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-7 text-center shadow-md">
<p className="thot-label text-primary">ThothII access</p>
<h1 className="mt-3 font-heading text-3xl font-semibold">Access not permitted</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">You are signed in without permission to use this workspace. Contact the installation administrator.</p>
</section>
</main>
);
}
return config ? <LoginPage config={config} onAuthenticated={(_authenticated: AuthenticatedUser) => {
setStatus("authenticated");
}} onRetry={retry} /> : null;
</main>}
</>;
}
+17 -21
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { AlertTriangle, ArrowRight, Eye, EyeOff, LockKeyhole } from "lucide-react";
@@ -23,6 +24,7 @@ function loginError(error: unknown): { message: string; retry: boolean } {
}
export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps) {
const { t } = useI18n();
const localLogin = config.mode === "local" && config.localLogin;
const oidcLogin = config.mode === "oidc" && config.oidcLogin;
const formRef = useRef<HTMLFormElement>(null);
@@ -77,27 +79,25 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
<main className="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 sm:py-12">
<div className="mx-auto grid min-h-[calc(100vh-4rem)] max-w-5xl items-center gap-12 lg:grid-cols-[minmax(0,1fr)_26rem]">
<section className="hidden max-w-xl lg:block">
<h1 className="max-w-lg font-heading text-5xl font-semibold leading-[1.03] tracking-tight sm:text-6xl">
From intent to SQL, with a human in the loop
</h1>
<p className="mt-6 max-w-md text-base leading-7 text-muted-foreground">AI generates, you guide and approve.</p>
<h1 className="max-w-lg font-heading text-5xl font-semibold leading-[1.03] tracking-tight sm:text-6xl">{t("From intent to SQL, with a human in the loop")}</h1>
<p className="mt-6 max-w-md text-base leading-7 text-muted-foreground">{t("AI generates, you guide and approve.")}</p>
</section>
<section className="mx-auto w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-md sm:p-8">
<div className="mb-7">
<div className="flex items-center gap-2 text-primary" aria-hidden="true">
<LockKeyhole className="size-4" />
<span className="thot-label text-primary">ThothII access</span>
<span className="thot-label text-primary">{t("ThothII access")}</span>
</div>
<h2 className="mt-3 font-heading text-3xl font-semibold tracking-tight">Sign in to ThothII</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">Use your installation account to continue.</p>
<h2 className="mt-3 font-heading text-3xl font-semibold tracking-tight">{t("Sign in to ThothII")}</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{t("Use your installation account to continue.")}</p>
</div>
{error && (
<div role="alert" aria-live="assertive" className="mb-5 grid gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
<p className="flex items-start gap-2 leading-5"><AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" />{error.message}</p>
<p className="flex items-start gap-2 leading-5"><AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" />{t(error.message)}</p>
{error.retry && onRetry && (
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={onRetry}>Retry</Button>
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={onRetry}>{t("Retry")}</Button>
)}
</div>
)}
@@ -105,7 +105,7 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
{localLogin && (
<form ref={formRef} onSubmit={submit} className="grid gap-4">
<div className="grid gap-1.5">
<label htmlFor="thothii-username" className="text-sm font-semibold">Username</label>
<label htmlFor="thothii-username" className="text-sm font-semibold">{t("Username")}</label>
<input
id="thothii-username"
name="username"
@@ -118,7 +118,7 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="thothii-password" className="text-sm font-semibold">Password</label>
<label htmlFor="thothii-password" className="text-sm font-semibold">{t("Password")}</label>
<div className="relative">
<input
ref={passwordRef}
@@ -134,9 +134,9 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? "Hide password" : "Show password"}
aria-label={showPassword ? t("Hide password") : t("Show password")}
aria-controls="thothii-password"
title={showPassword ? "Hide password" : "Show password"}
title={showPassword ? t("Hide password") : t("Show password")}
disabled={submitting}
onClick={() => setShowPassword((visible) => !visible)}
>
@@ -148,25 +148,21 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input name="remember" type="checkbox" className="size-4 accent-[oklch(var(--primary))]" />
<span>Remember me for 30 days</span>
<span>{t("Remember me for 30 days")}</span>
</label>
<Button type="submit" size="lg" className="mt-1 w-full" disabled={submitting}>
{submitting ? "Signing in…" : "Sign in"}
{submitting ? t("Signing in…") : t("Sign in")}
{!submitting && <ArrowRight aria-hidden="true" />}
</Button>
</form>
)}
{oidcLogin && (
<Button type="button" size="lg" className="w-full" onClick={startOidcLogin}>
Continue with single sign-on
<ArrowRight aria-hidden="true" />
<Button type="button" size="lg" className="w-full" onClick={startOidcLogin}>{t("Continue with single sign-on")}<ArrowRight aria-hidden="true" />
</Button>
)}
{!localLogin && !config.oidcLogin && (
<p role="status" className="rounded-md border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
No browser sign-in method is enabled for this installation.
</p>
<p role="status" className="rounded-md border border-border bg-muted/40 p-3 text-sm text-muted-foreground">{t("No browser sign-in method is enabled for this installation.")}</p>
)}
</section>
</div>
+16
View File
@@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react";
import type { AuthenticatedUser } from "../api/types";
import { queryClient } from "../app/queryClient";
import { useSessionStore } from "../store/sessionStore";
import { rememberSession } from "../shell/host/rememberedSession";
let current: AuthenticatedUser | null = null;
let generation = 0;
@@ -22,13 +23,28 @@ export function getAuthState(): AuthenticatedUser | null {
}
export function setAuthState(user: AuthenticatedUser): void {
if (current && (current.issuer !== user.issuer || current.subject !== user.subject)) rememberSession(current, null);
scrubUserBoundState();
current = user;
generation += 1;
notify();
}
/** Refresh expiry/CSRF metadata without discarding the current user's work. */
export function refreshAuthState(user: AuthenticatedUser): void {
if (!current || current.issuer !== user.issuer || current.subject !== user.subject
|| current.isAdmin !== user.isAdmin
|| JSON.stringify([...current.permissions].sort()) !== JSON.stringify([...user.permissions].sort())
|| JSON.stringify([...current.roles].sort()) !== JSON.stringify([...user.roles].sort())) {
setAuthState(user);
return;
}
current = user;
notify();
}
export function clearAuthState(): void {
if (current) rememberSession(current, null);
scrubUserBoundState();
current = null;
generation += 1;
+16
View File
@@ -0,0 +1,16 @@
import { authErrorStatus, getMe } from "../api/auth";
import { clearAuthStateIfCurrent, getAuthGeneration, getAuthState, isAuthGenerationCurrent, refreshAuthState } from "./authState";
/** Shared by page-return and stream-reconnection probes. Only /me denial revokes app access. */
export async function checkAccess() {
const generation = getAuthGeneration();
try {
const user = await getMe();
if (getAuthState() && isAuthGenerationCurrent(generation)) refreshAuthState(user);
return user;
} catch (error) {
const status = authErrorStatus(error);
if (status === 401 || status === 403) clearAuthStateIfCurrent(generation);
throw error;
}
}
+11 -1
View File
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import { setLocale } from "../i18n";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { ErrorBoundary } from "./ErrorBoundary";
@@ -16,9 +17,18 @@ beforeEach(() => {
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
act(() => setLocale("en"));
errorSpy.mockRestore();
});
test("updates an already displayed error boundary when the locale changes", () => {
render(<ErrorBoundary label="document"><Boom boom label="SELECT * malformed" /></ErrorBoundary>);
act(() => setLocale("it"));
expect(screen.getByRole("alert")).toHaveTextContent("Impossibile visualizzare questo elemento (documento).");
expect(screen.getByRole("button", { name: "Riprova" })).toBeVisible();
expect(screen.getByText("SELECT * malformed")).toBeInTheDocument();
});
test("renders children when nothing throws", () => {
render(
<ErrorBoundary>
+8 -8
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
@@ -67,29 +68,28 @@ function DefaultFallback({
label?: string;
onRetry: () => void;
}) {
const subject = label ? `${label} ` : "";
const { t: translate } = useI18n();
return (
<div
role="alert"
className="rounded-xl border border-destructive/25 bg-destructive/5 px-4 py-3 text-sm"
>
<p className="font-medium text-foreground">This {subject}couldn't be displayed.</p>
<p className="font-medium text-foreground">{label
? translate("This {subject} couldn't be displayed.", { subject: label === "step" || label === "document" ? translate(label) : label })
: translate("This couldn't be displayed.")}</p>
<p className="mt-1 leading-relaxed text-muted-foreground">
Something went wrong while rendering it. The rest of the session is unaffected.
</p>
{translate("Something went wrong while rendering it. The rest of the session is unaffected.")}</p>
<div className="mt-3 flex items-center gap-3">
<button
type="button"
onClick={onRetry}
className="rounded-md border border-border/70 bg-card px-2.5 py-1 text-xs font-semibold shadow-xs transition-colors hover:bg-muted"
>
Try again
</button>
{translate("Try again")}</button>
{error.message && (
<details className="min-w-0">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
Details
</summary>
{translate("Details")}</summary>
<pre className="mt-1.5 max-w-full overflow-x-auto rounded-md bg-muted px-2.5 py-1.5 font-mono text-[0.7rem] text-muted-foreground">
{error.message}
</pre>
+5 -3
View File
@@ -1,4 +1,5 @@
"use client"
import { useI18n } from "../../i18n";
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
@@ -47,6 +48,7 @@ function DialogContent({
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
const { t: translate } = useI18n();
return (
<DialogPortal>
<DialogOverlay />
@@ -74,7 +76,7 @@ function DialogContent({
>
<XIcon
/>
<span className="sr-only">Close</span>
<span className="sr-only">{translate("Close")}</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
@@ -100,6 +102,7 @@ function DialogFooter({
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
const { t: translate } = useI18n();
return (
<div
data-slot="dialog-footer"
@@ -112,8 +115,7 @@ function DialogFooter({
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
{translate("Close")}</DialogPrimitive.Close>
)}
</div>
)
+4
View File
@@ -1,14 +1,17 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
import { useI18n } from "../../i18n"
const Toaster = ({ ...props }: ToasterProps) => {
const { t } = useI18n()
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
containerAriaLabel={t("Notifications")}
icons={{
success: (
<CircleCheckIcon className="size-4" />
@@ -35,6 +38,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
} as React.CSSProperties
}
toastOptions={{
closeButtonAriaLabel: t("Close notification"),
classNames: {
toast: "cn-toast",
},
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from "react";
/** Protect edits until reverted or unmounted after the host accepts the response. */
export function useUnsavedDraft(draft: string, active = true) {
const [initialDraft] = useState(draft);
const dirty = active && draft !== initialDraft;
useEffect(() => {
if (!dirty) return;
const protectDraft = (event: BeforeUnloadEvent) => {
event.preventDefault();
// The browser supplies its own localized warning.
event.returnValue = "";
};
window.addEventListener("beforeunload", protectDraft);
return () => window.removeEventListener("beforeunload", protectDraft);
}, [dirty]);
}
+23
View File
@@ -0,0 +1,23 @@
import { afterEach, expect, test } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { resolveLocale, setLocale, translate, useI18n } from "./index";
afterEach(() => setLocale("en"));
test("Italian regional preferences translate controls and a missing message falls back to English", () => {
setLocale("it-IT");
expect(translate("Language")).toBe("Lingua");
expect(translate("Unknown message {name}", { name: "Ada" })).toBe("Unknown message Ada");
expect(resolveLocale("fr-FR")).toBe("en");
});
test("changing locale updates an already mounted form", () => {
function Form() {
const { t } = useI18n();
return <button>{t("Save")}</button>;
}
render(<Form />);
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
act(() => setLocale("it"));
expect(screen.getByRole("button", { name: "Salva" })).toBeInTheDocument();
});
+73
View File
@@ -0,0 +1,73 @@
import { useCallback, useSyncExternalStore } from "react";
import { itCore } from "./locales/it-core";
import { itAdmin } from "./locales/it-admin";
import { itWorkflow } from "./locales/it-workflow";
import { itViewers } from "./locales/it-viewers";
import { itGate } from "./locales/it-gate";
type Messages = Readonly<Record<string, string>>;
export type TranslationParams = Readonly<Record<string, string | number>>;
// English source messages are stable gettext-style identifiers and the fallback.
// A new language only needs a catalogue entry here; consumers stay unchanged.
const catalogs: Readonly<Record<string, Messages>> = {
en: {},
it: { ...itCore, ...itAdmin, ...itWorkflow, ...itViewers, ...itGate },
};
export const availableLocales = Object.freeze(Object.keys(catalogs).map((code) => {
const nativeName = new Intl.DisplayNames([code], { type: "language" }).of(code) ?? code;
return { code, name: nativeName.charAt(0).toLocaleUpperCase(code) + nativeName.slice(1) };
}));
export function resolveLocale(value: string | undefined): string {
if (!value) return "en";
try {
const tag = Intl.getCanonicalLocales(value)[0].toLowerCase();
if (Object.hasOwn(catalogs, tag)) return tag;
const language = tag.split("-")[0];
return Object.hasOwn(catalogs, language) ? language : "en";
} catch {
return "en";
}
}
let locale = "en";
const listeners = new Set<() => void>();
export function getLocale(): string {
return locale;
}
export function setLocale(value: string): void {
const next = resolveLocale(value);
if (next === locale) return;
locale = next;
listeners.forEach((listener) => listener());
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function translateIn(language: string, message: string, params?: TranslationParams): string {
const translated = catalogs[language]?.[message] ?? message;
return translated.replace(/\{([A-Za-z][A-Za-z0-9_]*)\}/g, (placeholder, name: string) =>
params && Object.hasOwn(params, name) ? String(params[name]) : placeholder,
);
}
/** For deterministic messages outside React; never use on model or workspace content. */
export function translate(message: string, params?: TranslationParams): string {
return translateIn(locale, message, params);
}
export function useI18n() {
const current = useSyncExternalStore(subscribe, getLocale, () => "en");
const t = useCallback(
(message: string, params?: TranslationParams) => translateIn(current, message, params),
[current],
);
return { locale: current, t };
}
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
export const itCore: Record<string, string> = {
"Session startup failed. Check configuration and connectivity, then Resume the session.": "Avvio della sessione non riuscito. Controlla configurazione e connessione, quindi riprendi la sessione.",
"Session services are not ready. Check configuration and connectivity, then try again.": "I servizi della sessione non sono pronti. Controlla configurazione e connessione, quindi riprova.",
"Session could not be resumed. Check configuration and connectivity, then try again.": "Impossibile riprendere la sessione. Controlla configurazione e connessione, quindi riprova.",
"Cannot start a session: the database is unreachable. Check the VPN connection and try again.": "Impossibile avviare la sessione: il database non è raggiungibile. Controlla la connessione VPN e riprova.",
"Selected model is unavailable. Check Pi authentication and model settings, then try again.": "Il modello selezionato non è disponibile. Controlla l'autenticazione Pi e le impostazioni del modello, quindi riprova.",
"Session workspace configuration is unavailable. Check configuration and try again.": "La configurazione del workspace della sessione non è disponibile. Controlla la configurazione e riprova.",
"Preparing retrieval context": "Preparazione del contesto di ricerca",
"Starting model": "Avvio del modello",
"Session created": "Sessione creata",
"Model request failed. Check provider connectivity, then Resume the session.": "Richiesta al modello non riuscita. Controlla la connessione al fornitore, quindi riprendi la sessione.",
"The selected model is unavailable for the current subscription. Choose another model and start a new session.": "Il modello selezionato non è disponibile con l’abbonamento attuale. Scegli un altro modello e avvia una nuova sessione.",
"Agent started": "Agente avviato",
"Agent finished": "Agente terminato",
"Phase started": "Fase avviata",
"Session exit": "Uscita dalla sessione",
"Turn end": "Fine del turno",
"System event": "Evento di sistema",
"Review requested": "Revisione richiesta",
"Application header": "Intestazione dell’applicazione",
"Interface language": "Lingua dell’interfaccia",
"Use dark theme": "Usa il tema scuro",
"Use light theme": "Usa il tema chiaro",
"Log out": "Esci",
"Fullscreen could not be changed. Try again or check your browser permissions.": "Non è stato possibile cambiare la modalità a schermo intero. Riprova o controlla le autorizzazioni del browser.",
"Logout could not be completed. Please try again.": "Non è stato possibile completare l’uscita. Riprova.",
"Shell unavailable": "Interfaccia non disponibile",
"Loading portal preferences…": "Caricamento delle preferenze del portale…",
"Portal preferences are unavailable. Reopen ThothII from the portal.": "Le preferenze del portale non sono disponibili. Riapri ThothII dal portale.",
"Invalid shell mode. Use full or embedded.": "Modalità dell’interfaccia non valida. Usa full o embedded.",
"A default interface language is required.": "È richiesta una lingua predefinita per l’interfaccia.",
"Unknown portal adapter. Check the installation configuration.": "Adapter del portale sconosciuto. Controlla la configurazione dell’installazione.",
"Checking access": "Verifica dell’accesso",
"Checking access…": "Verifica dell’accesso…",
"ThothII access": "Accesso a ThothII",
"Authentication unavailable": "Autenticazione non disponibile",
"Access not permitted": "Accesso non consentito",
"Portal access required": "È necessario l’accesso al portale",
"The authentication provider could not be reached. Try again in a moment.": "Il servizio di autenticazione non è raggiungibile. Riprova tra poco.",
"Your portal access is no longer available. Return to the portal to sign in or request access, then reopen ThothII.": "L’accesso al portale non è più disponibile. Torna al portale per accedere o richiedere l’accesso, quindi riapri ThothII.",
"You are signed in without permission to use this workspace. Contact the installation administrator.": "Hai effettuato l’accesso ma non hai il permesso di usare questo workspace. Contatta l’amministratore dell’installazione.",
"Authentication is temporarily unavailable. Try again.": "L’autenticazione è temporaneamente non disponibile. Riprova.",
"This sign-in request was rejected. Open ThothII from its configured address and try again.": "Questa richiesta di accesso è stata rifiutata. Apri ThothII dall’indirizzo configurato e riprova.",
"Invalid username or password.": "Nome utente o password non validi.",
"From intent to SQL, with a human in the loop": "Dalla domanda a SQL, con la supervisione umana",
"AI generates, you guide and approve.": "L’IA genera, tu guidi e approvi.",
"Sign in to ThothII": "Accedi a ThothII",
"Use your installation account to continue.": "Usa l’account della tua installazione per continuare.",
"Hide password": "Nascondi password",
"Show password": "Mostra password",
"Remember me for 30 days": "Ricordami per 30 giorni",
"Signing in…": "Accesso in corso…",
"Continue with single sign-on": "Continua con l’accesso unico",
"No browser sign-in method is enabled for this installation.": "Per questa installazione non è abilitato alcun metodo di accesso dal browser.",
"Save your administration changes, or cancel the edit, before leaving this page.": "Salva le modifiche amministrative o annulla la modifica prima di lasciare questa pagina.",
"Resuming session": "Ripresa della sessione",
"Failed to resume session.": "Impossibile riprendere la sessione.",
"Failed to move session.": "Impossibile spostare la sessione.",
"New group:": "Nuovo gruppo:",
"Failed to update group.": "Impossibile aggiornare il gruppo.",
"Failed to rename group.": "Impossibile rinominare il gruppo.",
"Failed to rename session.": "Impossibile rinominare la sessione.",
"Failed to restore session.": "Impossibile ripristinare la sessione.",
"Failed to archive session.": "Impossibile archiviare la sessione.",
"{author}'s session": "la sessione di {author}",
"this session": "questa sessione",
"Archive {label}?": "Archiviare {label}?",
"Deleted {deleted} of {total} sessions.": "Eliminate {deleted} sessioni su {total}.",
"Failed to delete selected sessions.": "Impossibile eliminare le sessioni selezionate.",
"Workspace preprocessing is required.": "È richiesta la preparazione del workspace.",
"Failed to create session. Your question is ready to retry.": "Impossibile creare la sessione. La domanda è pronta per un nuovo tentativo.",
"Working context required": "È necessario un contesto di lavoro",
"Choose your working context": "Scegli il contesto di lavoro",
"Select an available workspace and AI model above to enable Core and Administration.": "Seleziona un workspace e un modello IA disponibili qui sopra per abilitare Sessione e Amministrazione.",
"Administration unavailable": "Amministrazione non disponibile",
"Your account does not have permission to open this page.": "Il tuo account non ha il permesso di aprire questa pagina.",
"Hide model activity": "Nascondi attività del modello",
"Show model activity": "Mostra attività del modello",
"Question": "Domanda",
"Session completed and finalized. The SQL and all phase documents are saved.": "Sessione completata e finalizzata. SQL e tutti i documenti delle fasi sono salvati.",
"Start a new question": "Inizia una nuova domanda",
"Session navigation": "Navigazione delle sessioni",
"Datamart Builder with": "Creazione di datamart con",
"Human-in-the-loop review": "revisione umana",
"Return to session": "Torna alla sessione",
"New session": "Nuova sessione",
"Administration": "Amministrazione",
"Database management permission is required": "È richiesto il permesso di gestione dei database",
"Database": "Database",
"Memory management permission is required": "È richiesto il permesso di gestione delle Memory",
"Memory": "Memory",
"Evidence management permission is required": "È richiesto il permesso di gestione delle Evidence",
"Evidence": "Evidence",
"Workspace": "Workspace",
"Pi management permission is required": "È richiesto il permesso di gestione di Pi",
"Pi configuration": "Configurazione Pi",
"Workspace readiness: {state}": "Preparazione del workspace: {state}",
"unavailable": "non disponibile",
"checking": "verifica in corso",
"ready": "pronto",
"required": "richiesta",
"running": "in corso",
"blocked": "bloccata",
"failed": "non riuscita",
"Session scope": "Ambito delle sessioni",
"My sessions": "Le mie sessioni",
"All sessions": "Tutte le sessioni",
"Administrator view: all sessions": "Vista amministratore: tutte le sessioni",
"Sessions": "Sessioni",
"Select all sessions": "Seleziona tutte le sessioni",
"Select all": "Seleziona tutto",
"Delete {count} selected sessions": "Elimina {count} sessioni selezionate",
"Delete ({count})": "Elimina ({count})",
"Active sessions": "Sessioni attive",
"Rename group {name}": "Rinomina gruppo {name}",
"Archive ({count})": "Archivio ({count})",
"Rename group": "Rinomina gruppo",
"Build datamarts from your database through a guided, human-in-the-loop workflow. Type your question in the box below to begin a session.": "Crea datamart dal tuo database con un flusso guidato e la supervisione umana. Scrivi la domanda nel campo qui sotto per iniziare una sessione.",
"Language": "Lingua",
"Save": "Salva",
"Cancel": "Annulla",
"Close": "Chiudi",
"Delete": "Elimina",
"Edit": "Modifica",
"Loading…": "Caricamento…",
"Loading...": "Caricamento…",
"Retry": "Riprova",
"Continue": "Continua",
"Back": "Indietro",
"Sign out": "Esci",
"Sign in": "Accedi",
"Username": "Nome utente",
"Password": "Password",
"Light mode": "Modalità chiara",
"Dark mode": "Modalità scura",
"Enter fullscreen": "Attiva schermo intero",
"Exit fullscreen": "Esci da schermo intero",
};
+24
View File
@@ -0,0 +1,24 @@
export const itGate: Record<string, string> = {
"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.",
"Usage: /torna <session_id> [N]": "Uso: /torna <session_id> [N]",
"Invalid target (current phase {phase}).": "Target non valido (fase corrente {phase}).",
"Save and proceed": "Salva e procedi",
"Reject": "Rifiuta",
"Approve or reject?": "Approvi o rifiuti?",
"Choose Save and proceed or Reject.": "Scegli Salva e procedi o Rifiuta.",
"Generate a datamart?": "Vuoi generare un datamart?",
"Yes, generate the datamart": "Sì, genera il datamart",
"No, skip the datamart": "No, salta il datamart",
"Select memories to apply to the question": "Seleziona le memory da applicare alla domanda",
"memories to apply": "memory da applicare",
"Apply selected memories": "Applica le memory selezionate",
"No reusable memories for this question; continuing to the next phase.": "Nessuna memory riutilizzabile per questa domanda; passo alla fase successiva.",
"Memory for future questions": "Memory per le domande future",
"Resolve archive conflict": "Risolvi il conflitto negli archivi",
"The correction could not complete. Review the current archive state before retrying.": "La correzione non è stata completata. Verifica lo stato attuale degli archivi prima di riprovare.",
"Decisions recorded in this phase (from the ledger)": "Decisioni registrate in questa fase (dal registro)",
"{count} tables curated": "{count} tabelle selezionate",
"step": "passaggio",
};
+48
View File
@@ -0,0 +1,48 @@
export const itViewers: Record<string, string> = {
"No artifact content available.": "Nessun contenuto disponibile per l'artefatto.",
"Copy SQL for {name}": "Copia SQL per {name}",
"Copy SQL": "Copia SQL",
"Copied": "Copiato",
"Copy failed": "Copia non riuscita",
"{count} fields": "{count} campi",
"{count} field": "{count} campo",
"{count} rows": "{count} righe",
"{count} row": "{count} riga",
"Horizontal": "Orizzontale",
"Vertical": "Verticale",
"passed": "superato",
"failed": "non riuscita",
"untested": "non verificato",
"success": "riuscito",
"error": "errore",
"ok": "ok",
"warning": "avviso",
"promoted": "selezionata",
"discarded": "esclusa",
"No preview rows": "Nessuna riga di anteprima",
"Filter {index}": "Filtro {index}",
"Column": "Colonna",
"Operator": "Operatore",
"Value": "Valore",
"Description": "Descrizione",
"Rationale": "Motivazione",
"Depends on": "Dipende da",
"No dependencies": "Nessuna dipendenza",
"Keys": "Chiavi",
"Tables": "Tabelle",
"Filters": "Filtri",
"Output columns": "Colonne di output",
"Question": "Domanda",
"Strategy": "Strategia",
"Execution order": "Ordine di esecuzione",
"CTE plan steps": "Passaggi del piano CTE",
"Open questions": "Questioni aperte",
"Graph": "Grafo",
"Table": "Tabella",
"Too many nodes to display in the graph. Use the table instead.": "Troppi nodi da visualizzare nel grafo. Usa la tabella.",
"Name": "Nome",
"Type": "Tipo",
"Why": "Motivazione",
"table": "tabella",
"column": "colonna",
};
+278
View File
@@ -0,0 +1,278 @@
// Operational UI only. English message IDs are the fallback; placeholders keep
// session names, model content and technical identifiers out of the catalogue.
export const itWorkflow: Record<string, string> = {
"AI interaction model": "Modello IA per l'interazione",
"An administrator must approve shared archive corrections. You can reject the proposals or continue reviewing this question.": "Le correzioni agli archivi condivisi richiedono l'approvazione di un amministratore. Puoi rifiutare le proposte o continuare la revisione della domanda.",
"Apply this correction to {archive}": "Applica questa correzione a {archive}",
"Approved": "Approvata",
"Approved decisions: {decisions}": "Decisioni approvate: {decisions}",
"Archive": "Archivia",
"Archived": "Archiviata",
"Archive conflict repair": "Correzione di un conflitto negli archivi",
"Artifact review": "Revisione dell'artefatto",
"Ask a question to start a new session…": "Fai una domanda per avviare una nuova sessione…",
"Before this proposal": "Prima della proposta",
"Card ID or proposal reference": "ID della scheda o riferimento alla proposta",
"Change context": "Cambia contesto",
"Choose a model": "Scegli un modello",
"Choose an administration page or a session.": "Scegli una pagina di amministrazione o una sessione.",
"Choose model": "Scegli modello",
"Choose workspace": "Scegli workspace",
"Close model activity": "Chiudi attività del modello",
"Close panel": "Chiudi pannello",
"Concepts, separated by commas": "Concetti, separati da virgole",
"Context question": "Domanda di contesto",
"Context refresh failed. Your current choices and unsaved work have been retained.": "Aggiornamento del contesto non riuscito. Le scelte attuali e il lavoro non salvato sono stati conservati.",
"Context usage": "Utilizzo del contesto",
"Context usage {percent}%": "Utilizzo del contesto {percent}%",
"Continue question review": "Continua la revisione della domanda",
"Continue with repair pending": "Continua con la correzione in sospeso",
"Current card being replaced": "Scheda attuale da sostituire",
"Declined": "Rifiutata",
"Describe your alternative…": "Descrivi la tua alternativa…",
"Deselect all": "Deseleziona tutto",
"Filter columns": "Filtra colonne",
"Filter columns…": "Filtra colonne…",
"Finish without saving Memory": "Termina senza salvare in Memory",
"Free steering (!)…": "Indicazioni libere (!)…",
"Global working context": "Contesto di lavoro globale",
"Input, cached, and output tokens": "Token in ingresso, in cache e in uscita",
"Link to an existing card": "Collega a una scheda esistente",
"Link to another proposed card…": "Collega a un'altra scheda proposta…",
"Links to other cards": "Collegamenti ad altre schede",
"Live model activity": "Attività del modello in tempo reale",
"Loading working context…": "Caricamento del contesto di lavoro…",
"Locked": "Bloccato",
"Memories": "Memorie",
"Memory for future questions": "Memory per le domande future",
"Memory summary": "Riepilogo delle memorie",
"Model activity": "Attività del modello",
"Model activity timeline": "Cronologia dell'attività del modello",
"Move to group ›": "Sposta nel gruppo ›",
"Navigation": "Navigazione",
"New group…": "Nuovo gruppo…",
"New question": "Nuova domanda",
"No activity yet.": "Nessuna attività al momento.",
"No artifact available.": "Nessun artefatto disponibile.",
"No columns match the filter.": "Nessuna colonna corrisponde al filtro.",
"No decisions recorded.": "Nessuna decisione registrata.",
"No group": "Nessun gruppo",
"No memories recorded.": "Nessuna memoria registrata.",
"No sessions yet.": "Non ci sono ancora sessioni.",
"Start with New session.": "Inizia con Nuova sessione.",
"None are adequate — reformulate": "Nessuna proposta è adeguata: riformula",
"Owner: {name}": "Autore: {name}",
"Physical dependencies": "Dipendenze fisiche",
"Processing time": "Tempo di elaborazione",
"Processing time (excludes time spent waiting on the reviewer)": "Tempo di elaborazione (esclude l'attesa del revisore)",
"Promote": "Includi",
"Proposed result": "Risultato proposto",
"Reason": "Motivo",
"Recommended": "Consigliata",
"Rename": "Rinomina",
"Rename session": "Rinomina sessione",
"Resolve archive conflict": "Risolvi il conflitto negli archivi",
"Resume": "Riprendi",
"Retry context": "Ricarica il contesto",
"Retry selected correction": "Riprova la correzione selezionata",
"Review the scope and wording, then choose what to save. Only selected cards and their links will enter the shared archive.": "Verifica ambito e formulazione, poi scegli cosa salvare. Solo le schede selezionate e i relativi collegamenti entreranno nell'archivio condiviso.",
"Save selected and finish": "Salva le selezionate e termina",
"Select an available workspace and model to enable Core and Administration. Unavailable saved choices are not replaced automatically.": "Seleziona un workspace e un modello disponibili per abilitare Core e Amministrazione. Le scelte salvate non disponibili non vengono sostituite automaticamente.",
"Select {name}": "Seleziona {name}",
"Send": "Invia",
"Sending response…": "Invio della risposta…",
"Session actions": "Azioni della sessione",
"Session name…": "Nome della sessione…",
"Session summary": "Riepilogo della sessione",
"Session documents could not be loaded.": "Impossibile caricare i documenti della sessione.",
"Something went wrong while rendering it. The rest of the session is unaffected.": "Si è verificato un errore durante la visualizzazione. Il resto della sessione resta disponibile.",
"Split view": "Vista affiancata",
"Steering": "Indicazioni",
"Stop & save": "Interrompi e salva",
"Stop & save session": "Interrompi e salva la sessione",
"Stop and save session": "Interrompi e salva la sessione",
"Stop the session?": "Interrompere la sessione?",
"The running process will be interrupted. Progress so far is saved, and you can resume the session later.": "Il processo in esecuzione verrà interrotto. I progressi vengono salvati e potrai riprendere la sessione in seguito.",
"These joins are required by the selected tables and are shown for review. To request a correction, use Other — specify.": "Questi join sono richiesti dalle tabelle selezionate e vengono mostrati per la revisione. Per richiedere una correzione, usa Altro: specifica.",
"Thinking level": "Livello di ragionamento",
"This {subject} couldn't be displayed.": "Impossibile visualizzare questo elemento ({subject}).",
"This couldn't be displayed.": "Impossibile visualizzare questo elemento.",
"To change the solution, return to SQL review.": "Per modificare la soluzione, torna alla revisione SQL.",
"Type your answer…": "Scrivi la tua risposta…",
"Unknown": "Sconosciuto",
"Unsupported widget (kind: {kind}) — respond manually": "Widget non supportato (tipo: {kind}): rispondi manualmente",
"Update existing card": "Aggiorna scheda esistente",
"WORKING CONTEXT": "CONTESTO DI LAVORO",
"Why these cards are linked": "Motivo del collegamento tra queste schede",
"Workflow progress": "Avanzamento del workflow",
"Working context is unavailable. Retry or ask the installation operator to check configuration.": "Il contesto di lavoro non è disponibile. Riprova o chiedi al responsabile dell'installazione di verificare la configurazione.",
"Workspace and model are locked until the current operation finishes.": "Workspace e modello sono bloccati fino al termine dell'operazione in corso.",
"Your last choices take precedence over installation defaults. Workspace and model are remembered independently.": "Le tue ultime scelte hanno la precedenza sui valori predefiniti dell'installazione. Workspace e modello vengono ricordati separatamente.",
"— model —": "— modello —",
"— workspace —": "— workspace —",
"low": "basso",
"medium": "medio",
"high": "alto",
"Last choice": "Ultima scelta",
"Installation default": "Valore predefinito dell'installazione",
"Stopped by user": "Interrotta dall'utente",
"Stopped by system error": "Interrotta per un errore di sistema",
"In progress": "In corso",
"In progress · {status}": "In corso · {status}",
"open": "aperta",
"closed": "interrotta",
"pending": "in attesa",
"done": "completata",
"Clarification": "Chiarimento",
"Rewrite": "Riscrittura",
"Schema linking": "Collegamento dello schema",
"Plan": "Piano",
"CTE build": "Costruzione CTE",
"Final SQL": "SQL finale",
"Datamart": "Datamart",
"Clarifying the question": "Chiarimento della domanda",
"Recalling relevant memory": "Recupero delle memorie utili",
"Rewriting the question": "Riscrittura della domanda",
"Linking the schema": "Collegamento dello schema",
"Planning the SQL": "Pianificazione della query",
"Building the CTEs": "Costruzione delle CTE",
"Finalizing the SQL": "Finalizzazione della query",
"Building the datamart": "Costruzione del datamart",
"{phase} {name} — {state}": "{phase} {name}: {state}",
"Phase {phase}": "Fase {phase}",
"Response": "Risposta",
"Original question": "Domanda originale",
"Revised question": "Domanda riformulata",
"Assumptions": "Assunzioni",
"Validation report": "Rapporto di validazione",
"Decisions": "Decisioni",
"Data preview": "Anteprima dei dati",
"decision": "decisione",
"question rewritten": "domanda riformulata",
"table excluded": "tabella esclusa",
"column excluded": "colonna esclusa",
"column corrected": "colonna corretta",
"join modified": "join modificato",
"evidence accepted": "evidence accettata",
"evidence rejected": "evidence rifiutata",
"ambiguity open": "ambiguità aperta",
"cte corrected": "CTE corretta",
"cte rejected": "CTE rifiutata",
"sql revised": "SQL rivisto",
"sql rejected": "SQL rifiutato",
"phase reopened": "fase riaperta",
"phase skipped": "fase saltata",
"memory summary reviewed": "riepilogo delle memorie revisionato",
"decision retracted": "decisione ritirata",
"value grounded": "valore associato allo schema",
"concept formula approved": "formula del concetto approvata",
"concept formula rejected": "formula del concetto rifiutata",
"step": "passaggio",
"document": "documento",
"Go back": "Torna indietro",
"Exit": "Esci",
"Other — specify": "Altro: specifica",
"Columns {selected}/{total}": "Colonne {selected}/{total}",
"Columns {total}": "Colonne {total}",
"{selected} of {total} selected": "{selected} di {total} selezionate",
"{count} sessions will be permanently deleted, including all of their documents. This action cannot be undone.": "{count} sessioni verranno eliminate definitivamente insieme a tutti i relativi documenti. Questa azione non può essere annullata.",
'"{name}" will be permanently deleted, including all of its documents. This action cannot be undone.': '"{name}" verrà eliminata definitivamente insieme a tutti i relativi documenti. Questa azione non può essere annullata.',
"subject": "oggetto",
"detail": "dettaglio",
"scope": "ambito",
"rationale": "motivazione",
"question": "domanda",
"database": "database",
"schema": "schema",
"{id} {field}": "{id} {field}",
"{id} concepts": "{id} concetti",
"{id} dependency {number} {field}": "{id} dipendenza {number} {field}",
"{id} link {number} target": "{id} collegamento {number} destinazione",
"{id} link {number} meaning": "{id} collegamento {number} significato",
"{id} add link": "{id} aggiungi collegamento",
"Identity": "Identità",
"Subject": "Oggetto",
"Detail": "Dettaglio",
"SQL": "SQL",
"Dependencies": "Dipendenze",
"Links": "Collegamenti",
"Purposes": "Finalità",
"Applies to": "Ambito di applicazione",
"Source history": "Cronologia delle fonti",
"Review items": "Elementi da revisionare",
"Meaning": "Significato",
"Rule": "Regola",
"Text": "Testo",
"Selected": "Selezionata",
"Awaiting your decision. No archive changes have been saved.": "In attesa della tua decisione. Nessuna modifica agli archivi è stata salvata.",
"Your choice is recorded. The file update needs recovery; activation is not confirmed.": "La tua scelta è registrata. L'aggiornamento del file richiede un recupero; l'attivazione non è confermata.",
"Correction saved. Index activation is incomplete; retry to make it available.": "Correzione salvata. L'attivazione dell'indice è incompleta; riprova per renderla disponibile.",
"Correction saved and active in the archive index.": "Correzione salvata e attiva nell'indice dell'archivio.",
"Proposals rejected. No archive changes were made.": "Proposte rifiutate. Nessuna modifica agli archivi è stata effettuata.",
"The archive changed after this correction. Review its current content before proposing another repair.": "L'archivio è cambiato dopo questa correzione. Verifica il contenuto attuale prima di proporne un'altra.",
"Your answer": "La tua risposta",
"Your alternative": "La tua alternativa",
"Manual response": "Risposta manuale",
"Close notification": "Chiudi notifica",
"Notifications": "Notifiche",
"Failed to send response: {error}": "Invio della risposta non riuscito: {error}",
"Failed to send response.": "Invio della risposta non riuscito.",
"Request failed. Please try again.": "Richiesta non riuscita. Riprova.",
"The service is temporarily unavailable. Please retry.": "Il servizio è temporaneamente non disponibile. Riprova.",
"The service is unavailable. Please retry.": "Il servizio non è disponibile. Riprova.",
"The database is unreachable. Please retry.": "Il database non è raggiungibile. Riprova.",
"The model provider is unavailable. Please retry.": "Il fornitore del modello non è disponibile. Riprova.",
"Please sign in to continue.": "Accedi per continuare.",
"Access is not permitted.": "Accesso non consentito.",
"The requested resource was not found.": "La risorsa richiesta non è stata trovata.",
"The request conflicts with current workspace state.": "La richiesta è in conflitto con lo stato attuale del workspace.",
"Too many requests. Try again later.": "Troppe richieste. Riprova più tardi.",
"The security check failed. Please retry.": "La verifica di sicurezza non è riuscita. Riprova.",
"The preprocessing request is invalid.": "La richiesta di preelaborazione non è valida.",
"Preprocessing is blocked by a current workspace prerequisite.": "La preelaborazione è bloccata da un prerequisito del workspace.",
"Preprocessing failed. Review the latest diagnostic and retry.": "Preelaborazione non riuscita. Esamina l'ultima diagnostica e riprova.",
"{stage}, stage {step} of {total}": "{stage}, fase {step} di {total}",
"Reference vectors and LSH are empty. Memory is preserved.": "I vettori Reference e LSH sono vuoti. Memory è conservata.",
"Building schema vectors and LSH indexes.": "Creazione dei vettori dello schema e degli indici LSH.",
"Indexing Evidence.": "Indicizzazione di Evidence.",
"Publishing the completed preprocessing state.": "Pubblicazione dello stato di preelaborazione completato.",
"Database configuration is required.": "È necessaria la configurazione del database.",
"This workspace has no database configuration in the PostgreSQL Catalog.": "Questo workspace non ha una configurazione del database nel Catalogo PostgreSQL.",
"Open Database management and configure the workspace database.": "Apri Gestione database e configura il database del workspace.",
"The database transport is not supported by the core runtime.": "Il trasporto del database non è supportato dal runtime Core.",
"The current database binding uses an SSH tunnel, which cannot be used by a ThothII session.": "Il collegamento attuale al database usa un tunnel SSH, non utilizzabile da una sessione ThothII.",
"Open Database management and select a supported runtime transport.": "Apri Gestione database e seleziona un trasporto supportato dal runtime.",
"Catalog synchronization is required.": "È necessaria la sincronizzazione del Catalogo.",
"Open Database management and run Synchronize schema.": "Apri Gestione database ed esegui Sincronizza schema.",
"Catalog synchronization is in progress.": "Sincronizzazione del Catalogo in corso.",
"The Catalog is being synchronized and its metadata revision is not stable yet.": "Il Catalogo è in fase di sincronizzazione e la revisione dei metadati non è ancora stabile.",
"Wait for schema synchronization to finish, then run preprocessing.": "Attendi il termine della sincronizzazione dello schema, poi esegui la preelaborazione.",
"Description generation is in progress.": "Generazione delle descrizioni in corso.",
"Catalog descriptions are still being generated for this database.": "La generazione delle descrizioni del Catalogo per questo database è ancora in corso.",
"Wait for description generation to finish, then run preprocessing.": "Attendi il termine della generazione delle descrizioni, poi esegui la preelaborazione.",
"Sensitivity analysis is in progress.": "Analisi della sensibilità in corso.",
"Catalog sensitivity metadata is still being analyzed for this database.": "L'analisi dei metadati di sensibilità del Catalogo per questo database è ancora in corso.",
"Wait for sensitivity analysis to finish, then run preprocessing.": "Attendi il termine dell'analisi della sensibilità, poi esegui la preelaborazione.",
"The workspace runtime configuration is unavailable.": "La configurazione runtime del workspace non è disponibile.",
"The active workspace revision or one of its required database secrets could not be resolved.": "Impossibile risolvere la revisione attiva del workspace o uno dei segreti richiesti per il database.",
"Check Workspace management and Database management before running preprocessing.": "Verifica Gestione workspace e Gestione database prima di eseguire la preelaborazione.",
"The Catalog snapshot could not be prepared.": "Impossibile preparare l'istantanea del Catalogo.",
"PostgreSQL Catalog metadata could not be read into a consistent preprocessing snapshot.": "Impossibile leggere i metadati del Catalogo PostgreSQL in un'istantanea coerente per la preelaborazione.",
"Check Catalog availability, then retry preprocessing.": "Verifica la disponibilità del Catalogo, poi riprova la preelaborazione.",
"The schema index could not be rebuilt.": "Impossibile ricostruire l'indice dello schema.",
"The schema indexing worker stopped before the Catalog snapshot was published to Qdrant.": "Il processo di indicizzazione dello schema si è interrotto prima della pubblicazione dell'istantanea del Catalogo in Qdrant.",
"Open Last run details below, check the core service log for this error code, then retry.": "Apri Dettagli dell'ultima esecuzione qui sotto, cerca questo codice errore nel registro del servizio Core, poi riprova.",
"Evidence preprocessing did not complete.": "La preelaborazione di Evidence non è stata completata.",
"The Evidence indexing worker stopped before it finished publishing the current workspace data.": "Il processo di indicizzazione di Evidence si è interrotto prima di completare la pubblicazione dei dati attuali del workspace.",
"The semantic index configuration is incompatible.": "La configurazione dell'indice semantico è incompatibile.",
"Qdrant rejected the collection configuration for the active embedding model.": "Qdrant ha rifiutato la configurazione della raccolta per il modello di embedding attivo.",
"Check embedding dimensions and Qdrant collection settings, then retry.": "Verifica le dimensioni degli embedding e le impostazioni della raccolta Qdrant, poi riprova.",
"The Evidence source was refused by policy.": "La fonte Evidence è stata rifiutata dalle regole di accesso.",
"The configured Evidence endpoint is not allowed by the installation egress policy.": "L'endpoint Evidence configurato non è consentito dalle regole di accesso in uscita dell'installazione.",
"Correct the Evidence source or its allowlist configuration, then retry.": "Correggi la fonte Evidence o la configurazione delle fonti consentite, poi riprova.",
"The workspace runtime could not be prepared.": "Impossibile preparare il runtime del workspace.",
"The active workspace or one of its required runtime bindings is not usable.": "Il workspace attivo o uno dei collegamenti runtime richiesti non è utilizzabile.",
"Check Workspace management and Database management, then retry.": "Verifica Gestione workspace e Gestione database, poi riprova.",
"Preprocessing did not complete.": "La preelaborazione non è stata completata.",
"The preprocessing worker stopped before the current Catalog revision was published.": "Il processo di preelaborazione si è interrotto prima della pubblicazione della revisione attuale del Catalogo.",
};
+104 -20
View File
@@ -1,18 +1,25 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@tailwind base;
/* Omics shares the document. Keep Tailwind's reset inside our mount and portals. */
@thoth-base {
@tailwind base;
}
@tailwind components;
@tailwind utilities;
body.thot-full-document { margin: 0; }
/*
* GSD theme — mirrors the Omics Portal design system (Manrope + Fraunces, warm
* off-white surfaces, #cb333b red). Tokens are RAW OKLCH triplets (L C H) so the
* Tailwind v3 config can wrap them as `oklch(var(--token) / <alpha-value>)` and
* opacity utilities (bg-primary/80, …) keep working. Dark tokens follow either a
* `.dark` class or the portal's `[data-bs-theme="dark"]`, ready for embedding.
* Tokens belong to the React mount and Base UI portal roots, never the host header.
*/
@layer base {
:root {
#root, [data-base-ui-portal] {
color-scheme: light;
--font-sans: "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Arial, sans-serif;
--font-heading: "Fraunces", "Source Serif Pro", Georgia, "Times New Roman", serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
@@ -64,8 +71,8 @@
--sidebar-ring: 0.5587 0.1881 23.2;
}
.dark,
[data-bs-theme="dark"] {
:is(.dark, [data-bs-theme="dark"]) :is(#root, [data-base-ui-portal]) {
color-scheme: dark;
--shadow-tint: 0 0 0;
--background: 0.2178 0 90;
--foreground: 0.9310 0 90;
@@ -101,77 +108,79 @@
--sidebar-ring: 0.6897 0.1779 16.9;
}
* {
:where(#root, [data-base-ui-portal]), :where(#root, [data-base-ui-portal]) * {
@apply border-border outline-ring/50;
}
/* A click should read as a physical state change, not only as a hover ending.
Keep the response short and compositor-friendly so repeated actions stay fast. */
button:not(:disabled) {
:where(#root, [data-base-ui-portal]) button:not(:disabled) {
transform-origin: center;
transition-property: color, background-color, border-color, box-shadow, filter, transform;
transition-duration: 140ms;
transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
button:not(:disabled):active {
:where(#root, [data-base-ui-portal]) button:not(:disabled):active {
transform: scale(0.97);
filter: brightness(0.94);
box-shadow: none;
}
@media (prefers-reduced-motion: reduce) {
button:not(:disabled) {
:where(#root, [data-base-ui-portal]) button:not(:disabled) {
transition-duration: 0.01ms;
}
button:not(:disabled):active {
:where(#root, [data-base-ui-portal]) button:not(:disabled):active {
transform: none;
filter: brightness(0.9);
box-shadow: none;
}
}
html {
@apply font-sans;
#root, [data-base-ui-portal] {
@apply font-sans text-foreground antialiased;
line-height: 1.5;
-webkit-text-size-adjust: 100%;
}
body {
#root {
@apply bg-background text-foreground antialiased;
font-feature-settings: "ss01", "cv11";
}
h1, h2, h3, h4 {
:where(#root, [data-base-ui-portal]) :is(h1, h2, h3, h4) {
font-family: var(--font-heading);
font-optical-sizing: auto;
letter-spacing: -0.015em;
}
code, pre, kbd, samp {
:where(#root, [data-base-ui-portal]) :is(code, pre, kbd, samp) {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
::selection {
:where(#root, [data-base-ui-portal]) ::selection {
background: oklch(var(--primary) / 0.16);
}
/* Thin, brand-tinted scrollbars */
* {
:where(#root, [data-base-ui-portal]) * {
scrollbar-width: thin;
scrollbar-color: oklch(var(--border)) transparent;
}
*::-webkit-scrollbar {
:where(#root, [data-base-ui-portal]) *::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
:where(#root, [data-base-ui-portal]) *::-webkit-scrollbar-thumb {
background: oklch(var(--border));
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
:where(#root, [data-base-ui-portal]) *::-webkit-scrollbar-thumb:hover {
background: oklch(var(--muted-foreground) / 0.5);
}
}
@@ -181,6 +190,70 @@
* Scoped so it never leaks into widgets.
*/
@layer components {
.thot-host {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
height: var(--thoth-app-height, 100dvh);
color: oklch(var(--foreground));
background: oklch(var(--background));
color-scheme: light;
}
.thot-host[data-theme="dark"] { color-scheme: dark; }
.thot-host--full { --thot-shell-gutter: max(20px, 1.5rem); }
.thot-host--full > .thot-context-layout {
margin-inline: var(--thot-shell-gutter);
border-inline: 1px solid oklch(var(--border));
}
.thot-host > .thot-context-layout { flex: 1; min-height: 0; height: auto; }
.thot-full-header {
display: flex;
flex: none;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1rem;
min-height: 3.75rem;
padding: 0.625rem var(--thot-shell-gutter);
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--card));
}
.thot-full-header__brand { font: 600 1.25rem/1.2 var(--font-heading); }
.thot-full-header__controls { display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem; min-width: 0; }
.thot-full-header__user { max-width: 15rem; min-width: 0; font-size: 0.8125rem; }
.thot-full-header__error { flex-basis: 100%; font-size: 0.8125rem; color: oklch(var(--destructive)); }
.thot-shell-select {
min-height: 2rem;
max-width: 9rem;
padding: 0.25rem 0.5rem;
border: 1px solid oklch(var(--input));
border-radius: var(--radius);
background: oklch(var(--background));
color: oklch(var(--foreground));
font: 600 0.8125rem/1.5 var(--font-sans);
}
.thot-shell-select:focus-visible { outline: 3px solid oklch(var(--ring) / 0.5); outline-offset: 2px; }
.thot-user-menu {
z-index: 50;
min-width: 10rem;
padding: 0.25rem;
border: 1px solid oklch(var(--border));
border-radius: var(--radius);
background: oklch(var(--popover));
color: oklch(var(--popover-foreground));
box-shadow: var(--shadow-md);
font: 500 0.875rem/1.5 var(--font-sans);
outline: none;
}
.thot-user-menu__item { padding: 0.375rem 0.5rem; border-radius: 0.25rem; cursor: default; outline: none; }
.thot-user-menu__item[data-highlighted] { background: oklch(var(--accent)); color: oklch(var(--accent-foreground)); }
.thot-access-state { flex: 1; display: grid; align-content: center; justify-items: center; gap: 1rem; padding: 2rem max(20px, 1.5rem); color: oklch(var(--foreground)); background: oklch(var(--background)); }
@media (max-width: 480px) {
.thot-full-header__controls { flex: 1; justify-content: flex-end; }
.thot-full-header__user { max-width: 9rem; }
}
/* Omics Portal and Thoth share one document in the embedded deployment.
Keep rail geometry on product-specific class names so Hyper/Bootstrap's
global spacing utilities (for example .px-4 !important) cannot resize it. */
@@ -322,8 +395,19 @@
50% { transform: scale(1.15); box-shadow: 0 0 0 5px oklch(var(--primary) / 0); }
}
/* Shiki emits both palettes; select its dark values without rebuilding SQL. */
:is(.dark, [data-bs-theme="dark"]) .shiki,
:is(.dark, [data-bs-theme="dark"]) .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
@layer components {
.thot-database-grid {
.thot-database-grid, .thot-preview-grid,
:is(.thot-database-grid, .thot-preview-grid) .ag-theme-alpine {
--ag-font-family: var(--font-sans);
--ag-font-size: 0.8125rem;
--ag-background-color: oklch(var(--card));
+13 -4
View File
@@ -32,6 +32,7 @@ function KeyedAuthenticatedShell() {
}
beforeEach(() => {
window.__THOTHII_CONFIG__ = { shell: { mode: "full", defaultLocale: "en" } };
clearAuthState();
workspacePreferences.reset();
useSessionStore.getState().resetSession();
@@ -47,6 +48,14 @@ beforeEach(() => {
);
});
afterEach(() => { delete window.__THOTHII_CONFIG__; });
async function clickLogout() {
const header = screen.getByRole("banner", { name: "Application header" });
await userEvent.click(within(header).getByRole("button", { name: getAuthState()?.displayName ?? getAuthState()!.subject }));
await userEvent.click(await screen.findByRole("menuitem", { name: "Log out" }));
}
describe("authenticated shell permissions", () => {
test("opens Memory administration independently of the core session", async () => {
renderShell({ subject: "admin", isAdmin: true, roles: ["admin"],
@@ -196,7 +205,7 @@ describe("authenticated shell permissions", () => {
}));
renderShell(user);
await userEvent.click(screen.getByRole("button", { name: "Log out" }));
await clickLogout();
await vi.waitFor(() => expect(logoutCalls).toBe(1));
expect(getAuthState()).toBeNull();
@@ -228,7 +237,7 @@ describe("authenticated shell permissions", () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setAuthState(userA);
render(<QueryClientProvider client={client}><AppShell canLogout /></QueryClientProvider>);
await userEvent.click(screen.getByRole("button", { name: "Log out" }));
await clickLogout();
await started;
act(() => setAuthState(userB));
@@ -242,7 +251,7 @@ describe("authenticated shell permissions", () => {
expect(getAuthState()).toMatchObject({ subject: "user-b" });
expect(client.getQueryData(["b-only"])).toEqual({ owner: "user-b" });
expect(useSessionStore.getState().transcript).toEqual([{ role: "assistant", text: "B transcript" }]);
expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "user-b" })).toBeInTheDocument();
});
test("handles a failed shell logout without an unhandled rejection", async () => {
@@ -258,7 +267,7 @@ describe("authenticated shell permissions", () => {
)));
try {
renderShell(user);
await userEvent.click(screen.getByRole("button", { name: "Log out" }));
await clickLogout();
await waitFor(() => expect(getAuthState()).toBeNull());
await new Promise((resolve) => setImmediate(resolve));
expect(rejection).not.toHaveBeenCalled();
@@ -1,11 +1,17 @@
import { act, render, screen } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { App } from "../App";
import { queryClient } from "../app/queryClient";
import { useSessionStore } from "../store/sessionStore";
import { server } from "../test/msw";
import { setLocale } from "../i18n";
import { toast } from "sonner";
beforeEach(() => {
setLocale("en");
localStorage.clear();
toast.dismiss();
window.__THOTHII_CONFIG__ = { shell: { mode: "full", defaultLocale: "en" } };
queryClient.clear();
useSessionStore.getState().resetSession();
server.use(
@@ -16,6 +22,7 @@ beforeEach(() => {
http.get("/api/models", () => HttpResponse.json({ models: [] })),
);
});
afterEach(() => { cleanup(); toast.dismiss(); setLocale("en"); delete window.__THOTHII_CONFIG__; });
test("session errors queued in the store become visible notifications", async () => {
render(<App />);
await screen.findByTestId("app-shell");
@@ -27,3 +34,30 @@ test("session errors queued in the store become visible notifications", async ()
expect(await screen.findByText("The selected model is unavailable for this subscription.")).toBeInTheDocument();
});
test.each([
["Model request failed. Check provider connectivity, then Resume the session.", "Richiesta al modello non riuscita. Controlla la connessione al fornitore, quindi riprendi la sessione."],
["Session startup failed. Check configuration and connectivity, then Resume the session.", "Avvio della sessione non riuscito. Controlla configurazione e connessione, quindi riprendi la sessione."],
])("visible deterministic notification %s follows language changes while model prose stays verbatim", async (failure, translatedFailure) => {
render(<App />);
await screen.findByTestId("app-shell");
act(() => {
useSessionStore.getState().applyEvent({ type: "info", level: "error", text: failure });
useSessionStore.getState().applyEvent({ type: "system_event", event: "agent_start" });
const lifecycle = useSessionStore.getState().activityLog.at(-1)!;
useSessionStore.getState().pushToast({ level: "info", text: lifecycle.text });
// A catalogue key outside the fixed notification vocabulary is still prose.
useSessionStore.getState().pushToast({ level: "info", text: "New session" });
});
expect(await screen.findByText(failure)).toBeInTheDocument();
expect(await screen.findByText("Agent started")).toBeInTheDocument();
act(() => setLocale("it"));
expect(await screen.findByText(translatedFailure)).toBeInTheDocument();
expect(await screen.findByText("Agente avviato")).toBeInTheDocument();
expect(screen.getByText("New session")).toBeInTheDocument();
expect(screen.queryByText(failure)).not.toBeInTheDocument();
act(() => setLocale("en"));
await waitFor(() => expect(screen.getAllByText(failure)).toHaveLength(1));
expect(screen.getAllByText("Agent started")).toHaveLength(1);
expect(useSessionStore.getState().toasts.map(item => item.text)).toEqual([failure, "Agent started", "New session"]);
});
+98 -97
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useSessionStream } from "../stream/useSessionStream";
import { useSessionStore } from "../store/sessionStore";
import { WidgetHost } from "./WidgetHost";
@@ -48,6 +49,10 @@ import { AdministrationHeader } from "./administration/AdministrationPage";
import { WorkingContextProvider, useWorkingContext } from "../workspaces/WorkingContext";
import { WorkingContextShelf } from "./WorkingContextShelf";
import { useInteractionModelBusy } from "../models/useInteractionModelBusy";
import { FullHeader } from "./host/FullHeader";
import { useShell } from "./host/ShellProvider";
import { SessionNotification } from "./host/SessionNotification";
import { readRememberedSession, rememberSession } from "./host/rememberedSession";
interface AppShellProps {
canLogout: boolean;
@@ -91,6 +96,8 @@ export function resolveDatabaseManagementPresentation({
}
export function AppShell({ canLogout }: AppShellProps) {
const { t } = useI18n();
const shell = useShell();
const workingContext = useWorkingContext();
const interactionBusy = useInteractionModelBusy();
const mutationBusy = useIsMutating() > 0;
@@ -186,7 +193,7 @@ export function AppShell({ canLogout }: AppShellProps) {
&& preprocessingQuery.data
&& preprocessingQuery.data.state !== "ready",
);
const { data: sessions = [] } = useQuery<SessionSummary[]>({
const { data: sessions = [], isSuccess: sessionsLoaded } = useQuery<SessionSummary[]>({
queryKey: ["sessions", sessionScope],
queryFn: async () => {
const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
@@ -198,6 +205,21 @@ export function AppShell({ canLogout }: AppShellProps) {
refetchInterval: 10_000,
});
const composerRef = useRef<HTMLTextAreaElement>(null);
const restoredSelection = useRef(false);
useEffect(() => {
if (restoredSelection.current || !authenticatedUser || !sessionsLoaded) return;
const savedId = readRememberedSession(authenticatedUser);
const saved = sessions.find(session => session.id === savedId);
// A saved selection may belong to another user. The initial mine list is
// not exhaustive for reviewers authorized to browse all sessions.
if (savedId && !saved && sessionScope === "mine" && canReadAllSessions) {
setSessionScope("all");
return;
}
restoredSelection.current = true;
if (saved) setPanelSession(saved);
else if (savedId) rememberSession(authenticatedUser, null);
}, [authenticatedUser, sessions, sessionsLoaded, sessionScope, canReadAllSessions]);
const queryClient = useQueryClient();
const [showActivity, setShowActivity] = useState(false);
@@ -264,6 +286,8 @@ export function AppShell({ canLogout }: AppShellProps) {
};
function selectActiveSession(id: string | null) {
restoredSelection.current = true;
if (authenticatedUser) rememberSession(authenticatedUser, id);
// Keep async Resume completions synchronized before React commits the state update.
if (activeSessionIdRef.current !== id) activeSessionEpochRef.current += 1;
activeSessionIdRef.current = id;
@@ -323,7 +347,7 @@ export function AppShell({ canLogout }: AppShellProps) {
: 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.");
toast.warning(t("Save your administration changes, or cancel the edit, before leaving this page."));
return false;
}
return true;
@@ -334,6 +358,8 @@ export function AppShell({ canLogout }: AppShellProps) {
if (!canLeaveDatabaseManagement()) return;
const s = sessions.find((x) => x.id === id);
if (!s) return;
restoredSelection.current = true;
if (authenticatedUser) rememberSession(authenticatedUser, id);
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
@@ -445,7 +471,7 @@ export function AppShell({ canLogout }: AppShellProps) {
latestResumeIntentRef.current?.token === operation.latestToken
&& latestResumeIntentRef.current.id === id
) {
toast.error("Failed to resume session.");
toast.error(t("Failed to resume session."));
}
}
}
@@ -457,11 +483,11 @@ export function AppShell({ canLogout }: AppShellProps) {
if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return;
refresh(guard);
} catch {
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to move session.");
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error(t("Failed to move session."));
}
}
async function newGroup(s: SessionSummary) {
const name = window.prompt("New group:");
const name = window.prompt(t("New group:"));
if (name && name.trim()) {
const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
@@ -470,7 +496,7 @@ export function AppShell({ canLogout }: AppShellProps) {
if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return;
refresh(guard);
} catch {
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to update group.");
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error(t("Failed to update group."));
}
}
}
@@ -487,7 +513,7 @@ export function AppShell({ canLogout }: AppShellProps) {
refresh(guard);
return true;
} catch {
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to rename group.");
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error(t("Failed to rename group."));
return false;
}
}
@@ -501,7 +527,7 @@ export function AppShell({ canLogout }: AppShellProps) {
refresh(guard);
} catch {
if (isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) {
toast.error("Failed to rename session.");
toast.error(t("Failed to rename session."));
}
}
}
@@ -515,15 +541,15 @@ export function AppShell({ canLogout }: AppShellProps) {
refresh(guard);
} catch {
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) {
toast.error(s.archived ? "Failed to restore session." : "Failed to archive session.");
toast.error(s.archived ? t("Failed to restore session.") : t("Failed to archive session."));
}
}
}
function requestArchiveToggle(session: SessionSummary) {
if (!session.archived && isForeignSession(session)) {
const label = session.author ? `${session.author}'s session` : "this session";
if (!window.confirm(`Archive ${label}?`)) return;
const label = session.author ? t("{author}'s session", { author: session.author }) : t("this session");
if (!window.confirm(t("Archive {label}?", { label }))) return;
}
void toggleArchive(session);
}
@@ -545,10 +571,10 @@ export function AppShell({ canLogout }: AppShellProps) {
setSelectedSessionIds((current) => new Set([...current].filter((id) => !deletedIds.has(id))));
refresh(guard);
if (deletedIds.size !== targets.length) {
toast.error(`Deleted ${deletedIds.size} of ${targets.length} sessions.`);
toast.error(t("Deleted {deleted} of {total} sessions.", { deleted: deletedIds.size, total: targets.length }));
}
} catch {
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to delete selected sessions.");
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error(t("Failed to delete selected sessions."));
}
}
@@ -566,7 +592,13 @@ export function AppShell({ canLogout }: AppShellProps) {
session={s}
groups={groups}
onResume={() => doResume(s.id)}
onView={() => { if (canLeaveDatabaseManagement()) { navigate({ surface: "core" }, true); setPanelSession(s); } }}
onView={() => {
if (!canLeaveDatabaseManagement()) return;
restoredSelection.current = true;
if (authenticatedUser) rememberSession(authenticatedUser, s.id);
navigate({ surface: "core" }, true);
setPanelSession(s);
}}
onRename={() => setRenameTarget(s)}
onMove={(g) => move(s, g)}
onNewGroup={() => newGroup(s)}
@@ -596,10 +628,11 @@ export function AppShell({ canLogout }: AppShellProps) {
}
for (const notification of sessionToasts.slice(deliveredToastCountRef.current)) {
if (notification.level === "error") toast.error(notification.text);
else if (notification.level === "success") toast.success(notification.text);
else if (notification.level === "warning") toast.warning(notification.text);
else toast.info(notification.text);
const message = <SessionNotification text={notification.text} />;
if (notification.level === "error") toast.error(message);
else if (notification.level === "success") toast.success(message);
else if (notification.level === "warning") toast.warning(message);
else toast.info(message);
}
deliveredToastCountRef.current = sessionToasts.length;
@@ -644,7 +677,7 @@ export function AppShell({ canLogout }: AppShellProps) {
function startNewSession() {
if (!workingContext.ready || administrationBusy) return;
if (preprocessingBlocksNewSession) {
toast.warning(preprocessingQuery.data?.detail ?? "Workspace preprocessing is required.");
toast.warning(preprocessingQuery.data?.detail ?? t("Workspace preprocessing is required."));
return;
}
if (!canLeaveDatabaseManagement()) return;
@@ -691,7 +724,7 @@ export function AppShell({ canLogout }: AppShellProps) {
if (!operation || operation.target !== activeSessionIdRef.current || operation.epoch !== activeSessionEpochRef.current) return;
setCreatingSession(false);
resetSession();
toast.error(message ?? "Failed to create session. Your question is ready to retry.");
toast.error(message ? <SessionNotification text={message} /> : t("Failed to create session. Your question is ready to retry."));
}
async function stopSession() {
@@ -743,6 +776,7 @@ export function AppShell({ canLogout }: AppShellProps) {
return (
<WorkingContextProvider value={{ workspaceId: workingContext.workspaceId, ready: workingContext.ready, locked: contextLocked, administrationBusy: administrationBusy || (interactionBusy && !agentActive) }}>
<AdministrationNavigationProvider navigate={navigate}>
<FullHeader onLogout={canLogout ? signOut : undefined} />
<div className="thot-context-layout">
<WorkingContextShelf context={workingContext} locked={contextLocked} onWorkspace={changeContextWorkspace} onModel={changeContextModel} />
<div
@@ -760,7 +794,10 @@ export function AppShell({ canLogout }: AppShellProps) {
{panelSession && (
<SessionDocumentsPanel
session={panelSession}
onClose={() => setPanelSession(null)}
onClose={() => {
setPanelSession(null);
if (authenticatedUser) rememberSession(authenticatedUser, activeSessionId);
}}
onResume={doResume}
desktopSplit={sessionDesktopSplit}
hidden={activeSurface !== "core"}
@@ -794,9 +831,9 @@ export function AppShell({ canLogout }: AppShellProps) {
{/* Conversation column */}
<WorkAreaPanelHost data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
{!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." />
{!workingContext.ready && <main className="thot-administration-page" aria-label={t("Working context required")}><AdministrationHeader title={t("Choose your working context")} description={t("Select an available workspace and AI model above to enable Core and Administration.")} /></main>}
{!administrationPermitted && <main className="thot-administration-page" aria-label={t("Administration unavailable")}>
<AdministrationHeader title={t("Administration unavailable")} description={t("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>
@@ -824,8 +861,8 @@ export function AppShell({ canLogout }: AppShellProps) {
type="button"
onClick={toggleActivity}
aria-expanded={showActivity}
aria-label={showActivity ? "Hide model activity" : "Show model activity"}
title={showActivity ? "Hide model activity" : "Show model activity"}
aria-label={showActivity ? t("Hide model activity") : t("Show model activity")}
title={showActivity ? t("Hide model activity") : t("Show model activity")}
className="absolute left-4 top-1/2 grid size-7 -translate-y-1/2 place-items-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{showActivity ? <ArrowLeft className="size-4" /> : <ArrowRight className="size-4" />}
@@ -846,7 +883,7 @@ export function AppShell({ canLogout }: AppShellProps) {
<>
{activeSession?.question && (
<header className="sticky top-0 z-10 -mx-6 -mt-8 border-b border-border/60 bg-background/95 px-6 pb-3 pt-8 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<p className="thot-label mb-0.5 text-muted-foreground">Question</p>
<p className="thot-label mb-0.5 text-muted-foreground">{t("Question")}</p>
<h2 className="font-heading text-[0.95rem] font-semibold leading-snug text-foreground break-words">
{activeSession.question}
</h2>
@@ -856,13 +893,8 @@ export function AppShell({ canLogout }: AppShellProps) {
{activeSessionId && <WidgetHost key={`widget:${activeSessionId}:${activeSessionEpochRef.current}`} sessionId={activeSessionId} />}
{finalized && !agentActive && (
<div className="rounded-2xl border border-border/80 bg-card p-5 text-center shadow-md">
<p className="text-sm text-muted-foreground">
Session completed and finalized — the SQL and all phase
documents are saved.
</p>
<Button className="mt-3" onClick={startNewSession}>
Start a new question
</Button>
<p className="text-sm text-muted-foreground">{t("Session completed and finalized. The SQL and all phase documents are saved.")}</p>
<Button className="mt-3" onClick={startNewSession}>{t("Start a new question")}</Button>
</div>
)}
</>
@@ -917,34 +949,23 @@ export function AppShell({ canLogout }: AppShellProps) {
{/* Right session rail */}
{(
<ArchiveNavigation surface={activeSurface} forceDrawer={activeSurface === "core" && showActivity}>
<aside aria-label="Session navigation" className="flex w-64 shrink-0 flex-col bg-sidebar">
<aside aria-label={t("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">
Thoth<span className="text-primary">II</span>
</h1>
<p className="thot-label mt-1.5">
Datamart Builder with
<br />
Human-in-the-loop review
</p>
{authenticatedUser && (
<p className="thot-label mt-1.5">{t("Datamart Builder with")}<br />{t("Human-in-the-loop review")}</p>
{authenticatedUser && shell.mode === "embedded" && (
<div className="mt-4 flex items-center justify-between gap-2 border-t border-border/70 pt-3 text-left">
<span className="min-w-0 truncate text-xs text-muted-foreground" title={authenticatedUser.displayName ?? authenticatedUser.subject}>
{authenticatedUser.displayName ?? authenticatedUser.subject}
</span>
{canLogout && (
<Button variant="ghost" size="xs" onClick={() => { void signOut().catch(() => undefined); }}>
Log out
</Button>
)}
</div>
)}
</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>}
{activeSurface !== "core" && <Button variant="outline" size="sm" className="w-full" onClick={() => navigate({ surface: "core" })}>{t("Return to session")}</Button>}
<Button
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
size="sm"
@@ -956,9 +977,7 @@ export function AppShell({ canLogout }: AppShellProps) {
disabled={preprocessingBlocksNewSession || !workingContext.ready || administrationBusy}
title={preprocessingBlocksNewSession ? preprocessingQuery.data?.detail : undefined}
onClick={startNewSession}
>
New session
</Button>
>{t("New session")}</Button>
{isAdmin && (
<Accordion.Root
value={adminNavigationValue}
@@ -976,7 +995,7 @@ export function AppShell({ canLogout }: AppShellProps) {
})}
data-navigation-state={!adminNavigationOpen && managementNavigationCurrent ? "current" : "available"}
>
<span>Administration</span>
<span>{t("Administration")}</span>
<ChevronDown
aria-hidden="true"
data-icon="inline-end"
@@ -992,33 +1011,31 @@ export function AppShell({ canLogout }: AppShellProps) {
aria-current={currentNavigation === "database" ? "page" : undefined}
data-navigation-state={currentNavigation === "database" ? "current" : canManageDatabase ? "available" : "unavailable"}
disabled={!canManageDatabase}
title={canManageDatabase ? undefined : "Database management permission is required"}
title={canManageDatabase ? undefined : t("Database management permission is required")}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
navigate({ surface: "database-management" }, true);
}}
>
Database
</Button>
>{t("Database")}</Button>
<Button
variant={currentNavigation === "memory" ? "navigationActive" : "outline"}
size="sm"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "memory" ? "page" : undefined}
disabled={!canManageMemory}
title={canManageMemory ? undefined : "Memory management permission is required"}
title={canManageMemory ? undefined : t("Memory management permission is required")}
data-navigation-state={currentNavigation === "memory" ? "current" : "available"}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
navigate({ surface: "memory-management" }, true);
}}
>Memory</Button>
>{t("Memory")}</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"}
disabled={!canManageEvidence} title={canManageEvidence ? undefined : t("Evidence management permission is required")}
onClick={() => navigate({ surface: "evidence-management" })}
>Evidence</Button>
>{t("Evidence")}</Button>
<div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" />
<Button
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
@@ -1030,9 +1047,7 @@ export function AppShell({ canLogout }: AppShellProps) {
if (!canLeaveDatabaseManagement()) return;
navigate({ surface: "workspace-management" }, true);
}}
>
Workspace
</Button>
>{t("Workspace")}</Button>
<Button
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
size="sm"
@@ -1040,16 +1055,13 @@ export function AppShell({ canLogout }: AppShellProps) {
aria-current={currentNavigation === "pi" ? "page" : undefined}
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
disabled={!canManagePi}
title={canManagePi ? undefined : "Pi management permission is required"}
title={canManagePi ? undefined : t("Pi management permission is required")}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
navigate({ surface: "pi-management" }, true);
}}
>
Pi configuration
</Button>
<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"}
>{t("Pi configuration")}</Button>
<Button variant="ghost" size="sm" className="w-full min-w-0 whitespace-normal" onClick={() => navigate({ surface: "workspace-management", workspace: selectedWorkspaceId })}>{t("Workspace readiness: {state}", { state: t(preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking") })}
</Button>
</Accordion.Panel>
</Accordion.Item>
@@ -1061,7 +1073,7 @@ export function AppShell({ canLogout }: AppShellProps) {
<div className="px-4">
<div
role="tablist"
aria-label="Session scope"
aria-label={t("Session scope")}
aria-orientation="horizontal"
className="grid grid-cols-2 border-b border-border"
>
@@ -1077,9 +1089,7 @@ export function AppShell({ canLogout }: AppShellProps) {
className={sessionScopeTabClass(sessionScope === "mine")}
onClick={() => setSessionScope("mine")}
onKeyDown={(event) => handleSessionScopeTabKeyDown(event, "mine")}
>
My sessions
</button>
>{t("My sessions")}</button>
<button
ref={(node) => { sessionScopeTabRefs.current.all = node; }}
id="session-scope-all-tab"
@@ -1092,9 +1102,7 @@ export function AppShell({ canLogout }: AppShellProps) {
className={sessionScopeTabClass(showingAllSessions)}
onClick={() => setSessionScope("all")}
onKeyDown={(event) => handleSessionScopeTabKeyDown(event, "all")}
>
All sessions
</button>
>{t("All sessions")}</button>
</div>
</div>
)}
@@ -1106,39 +1114,35 @@ export function AppShell({ canLogout }: AppShellProps) {
className="flex min-h-0 flex-1 flex-col"
>
{canReadAllSessions && showingAllSessions && (
<p className="mx-4 mb-2 mt-2 text-xs font-medium text-amber-700 dark:text-amber-400">
Administrator view: all sessions
</p>
<p className="mx-4 mb-2 mt-2 text-xs font-medium text-amber-700 dark:text-amber-400">{t("Administrator view: all sessions")}</p>
)}
{/* L1 — rail title */}
<div className="px-4 pb-1.5 pt-1">
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">
Sessions
</span>
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">{t("Sessions")}</span>
</div>
<div className="flex items-center justify-between px-4 pb-2">
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground">
<Checkbox
checked={allSessionsSelected}
aria-label="Select all sessions"
aria-label={t("Select all sessions")}
disabled={sessions.length === 0}
onCheckedChange={(selected) => toggleAllSessions(selected === true)}
/>
<span>Select all</span>
<span>{t("Select all")}</span>
</label>
{selectedSessions.length > 0 && (
<Button
variant="destructive"
size="xs"
aria-label={`Delete ${selectedSessions.length} selected sessions`}
aria-label={t("Delete {count} selected sessions", { count: selectedSessions.length })}
onClick={() => {
requestDelete(selectedSessions);
}}
>
<Trash2 />
Delete ({selectedSessions.length})
{t("Delete ({count})", { count: selectedSessions.length })}
</Button>
)}
</div>
@@ -1151,7 +1155,7 @@ export function AppShell({ canLogout }: AppShellProps) {
className="thot-label flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-foreground/65 hover:text-foreground"
>
<span className="select-none">{activeOpen ? "▾" : "▸"}</span>
<span>Active sessions</span>
<span>{t("Active sessions")}</span>
</button>
{activeOpen && (
<div className="flex flex-col gap-3 pb-2">
@@ -1170,7 +1174,7 @@ export function AppShell({ canLogout }: AppShellProps) {
</button>
<button
type="button"
aria-label={`Rename group ${g}`}
aria-label={t("Rename group {name}", { name: g })}
onClick={() => setRenameGroupTarget(g)}
className="rounded-md p-0.5 text-muted-foreground opacity-0 transition-opacity hover:bg-accent group-hover/gh:opacity-100"
>
@@ -1215,7 +1219,7 @@ export function AppShell({ canLogout }: AppShellProps) {
className="thot-label mt-2 flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-foreground/65 hover:text-foreground"
>
<span className="select-none">{archiveOpen ? "▾" : "▸"}</span>
<span className="tabular-nums">Archive ({archivedList.length})</span>
<span className="tabular-nums">{t("Archive ({count})", { count: archivedList.length })}</span>
</button>
{archiveOpen && (
<NavSessions
@@ -1233,7 +1237,7 @@ export function AppShell({ canLogout }: AppShellProps) {
</aside>
</ArchiveNavigation>
)}
<Toaster />
<Toaster theme={shell.theme} />
<StopConfirmDialog
open={stopConfirm}
@@ -1261,7 +1265,7 @@ export function AppShell({ canLogout }: AppShellProps) {
setRenameGroupTarget(null);
}
}}
title="Rename group"
title={t("Rename group")}
/>
)}
{deleteTargets.length > 0 && (
@@ -1284,16 +1288,13 @@ export function AppShell({ canLogout }: AppShellProps) {
}
function EmptyState() {
const { t } = useI18n();
return (
<div className="flex min-h-[72vh] flex-col items-center justify-center text-center">
<span className="font-heading text-5xl font-semibold tracking-tight text-foreground">
Thoth<span className="text-primary">II</span>
</span>
<p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground">
Build datamarts from your database through a guided,
human-in-the-loop workflow. Type your question in the box below to begin
a session.
</p>
<p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground">{t("Build datamarts from your database through a guided, human-in-the-loop workflow. Type your question in the box below to begin a session.")}</p>
</div>
);
}
+5 -3
View File
@@ -1,9 +1,11 @@
import { useI18n } from "../i18n";
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";
/** 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 { t: translate } = useI18n();
const marker = useRef<HTMLSpanElement>(null);
const [narrow, setNarrow] = useState(false);
const [open, setOpen] = useState(false);
@@ -28,11 +30,11 @@ export function ArchiveNavigation({ surface, forceDrawer = false, 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>
<DialogTrigger render={<Button variant="outline" size="sm" />}>{translate("Navigation")}</DialogTrigger>
</div>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col">
<DialogTitle>Navigation</DialogTitle>
<DialogDescription className="sr-only">Choose an administration page or a session.</DialogDescription>
<DialogTitle>{translate("Navigation")}</DialogTitle>
<DialogDescription className="sr-only">{translate("Choose an administration page or a session.")}</DialogDescription>
<div className="min-h-0 overflow-y-auto [&>aside]:w-full">{children}</div>
</DialogContent>
</Dialog></>;
+3 -1
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useLayoutEffect, useRef } from "react";
import { useSessionStore } from "../store/sessionStore";
import { isNearBottom } from "./activityScroll";
@@ -15,6 +16,7 @@ function transcriptLines(transcript: Array<{ text: string }>): string[] {
/** Compact central projection of the assistant stream while the model is working. */
export function CentralStatus({ working }: { working: boolean }) {
const { t: translate } = useI18n();
const transcript = useSessionStore((state) => state.transcript);
const rows = transcriptLines(transcript);
const rowCount = rows.length;
@@ -37,7 +39,7 @@ export function CentralStatus({ working }: { working: boolean }) {
<ol
ref={scrollRef}
role="log"
aria-label="Live model activity"
aria-label={translate("Live model activity")}
aria-live="polite"
aria-relevant="additions text"
tabIndex={0}
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { setLocale } from "../i18n";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
@@ -115,6 +116,22 @@ function renderPage({
return { ...view, client };
}
test("updates database row actions and the open editor when switching EN and IT", async () => {
renderPage({ presentation: "fleet" });
await userEvent.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
const name = await screen.findByLabelText("Database name");
const savedName = (name as HTMLInputElement).value;
try {
act(() => setLocale("it"));
expect(screen.getByLabelText("Nome database")).toHaveValue(savedName);
expect(screen.getByRole("button", { name: "Salva modifiche" })).toBeVisible();
expect(screen.getByRole("button", { name: "Verifica connessione" })).toBeVisible();
act(() => setLocale("en"));
expect(screen.getByLabelText("Database name")).toHaveValue(savedName);
expect(screen.getByRole("button", { name: "Save changes" })).toBeVisible();
} finally { act(() => setLocale("en")); }
});
test("fleet entry opens the database list without workspace preparation or an automatic editor", async () => {
renderPage({ presentation: "fleet" });
expect(await screen.findByRole("button", { name: "View tables for Policlinico San Donato" })).toBeVisible();
+153 -156
View File
@@ -1,3 +1,5 @@
import { getLocale, useI18n } from "../i18n";
import { adminMessage, renderAdminMessage, type AdminMessage } from "./database-management/messages";
import {
useCallback,
useEffect,
@@ -106,8 +108,8 @@ interface FormSource {
interface ConnectionTestResult {
outcome: "success" | "failure";
title: string;
description: string;
title: AdminMessage;
description: AdminMessage;
}
function mergeConnectionTestResult(
@@ -149,6 +151,7 @@ export function DatabaseManagementPage({
onNavigationStateChange,
presentation = "legacy",
}: Props) {
const { t } = useI18n();
const workingContext = useWorkingContextScope();
const queryClient = useQueryClient();
const {
@@ -223,7 +226,7 @@ export function DatabaseManagementPage({
const handleTableNestedNavigationChange = useCallback((active: boolean, onBack?: () => void) => {
setTableNestedNavigationActive(active);
setTableNestedNavigationBack(() => (active ? onBack ?? null : null));
}, []);
}, [t]);
const [activeSyncRun, setActiveSyncRun] = useState<CatalogSyncRun | null>(null);
const [syncHistoryDatabaseId, setSyncHistoryDatabaseId] = useState<string | null>(null);
const [syncDrawerOpen, setSyncDrawerOpen] = useState(false);
@@ -234,7 +237,7 @@ export function DatabaseManagementPage({
const [sensitivityAnalysisStarting, setSensitivityAnalysisStarting] = useState(false);
const [sensitiveReview, setSensitiveReview] = useState<{
databaseId: string;
scopeLabel: string;
scopeLabel: AdminMessage;
suggestions: SensitivityReviewItem[];
} | null>(null);
@@ -265,7 +268,7 @@ export function DatabaseManagementPage({
});
const invalidateCatalogMetrics = useCallback(
() => queryClient.invalidateQueries({ queryKey: ["catalog-metrics"] }),
[queryClient],
[queryClient, t],
);
const editable = screen.kind === "configure" || screen.kind === "edit";
const configurationDirty = Boolean(
@@ -319,7 +322,7 @@ export function DatabaseManagementPage({
if (existing < 0) return [...current, saved];
return current.map((row, index) => index === existing ? saved : row);
});
}, [queryClient]);
}, [queryClient, t]);
const restoreListFocus = useCallback(() => {
const originLabel = originRef.current?.getAttribute("aria-label");
@@ -334,7 +337,7 @@ export function DatabaseManagementPage({
searchInputRef.current?.focus();
}
}, 0);
}, []);
}, [t]);
const showList = useCallback(() => {
setScreen({ kind: "list" });
@@ -348,25 +351,25 @@ export function DatabaseManagementPage({
setTableNestedNavigationActive(false);
setTableNestedNavigationBack(null);
restoreListFocus();
}, [restoreListFocus]);
}, [restoreListFocus, t]);
useEffect(() => {
if (screen.kind === "list" || isLoading || activeRow) return;
showList();
toast.info("This database configuration is no longer available");
toast.info(t("This database configuration is no longer available"));
}, [activeRow, isLoading, screen.kind, showList]);
useEffect(() => {
if (!["tables", "relationships"].includes(screen.kind) || !activeRow || (activeRow.configured && activeRow.id)) return;
showList();
toast.info("Save the database configuration before managing tables");
toast.info(t("Save the database configuration before managing tables"));
}, [activeRow, screen.kind, showList]);
const backToList = useCallback(() => {
if (busy) return;
if (dirty && !window.confirm("Discard unsaved database changes?")) return;
if (dirty && !window.confirm(t("Discard unsaved database changes?"))) return;
showList();
}, [busy, dirty, showList]);
}, [busy, dirty, showList, t]);
const openForm = useCallback((
mode: DatabaseFormMode,
@@ -385,19 +388,19 @@ export function DatabaseManagementPage({
kind: mode,
workspaceId: row.workspaceId,
});
}, []);
}, [t]);
const viewRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
openForm("view", row, origin);
}, [openForm]);
}, [openForm, t]);
const editRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
openForm(row.configured ? "edit" : "configure", row, origin);
}, [openForm]);
}, [openForm, t]);
const deleteRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
openForm("delete", row, origin);
}, [openForm]);
}, [openForm, t]);
const openTables = useCallback((row: CatalogDatabase, origin?: HTMLElement) => {
if (!row.configured || !row.id) return;
@@ -411,7 +414,7 @@ export function DatabaseManagementPage({
setTableNestedNavigationActive(false);
setTableNestedNavigationBack(null);
setScreen({ kind: "tables", workspaceId: row.workspaceId });
}, []);
}, [t]);
const openRelationships = useCallback((row: CatalogDatabase, origin?: HTMLElement) => {
if (!row.configured || !row.id) return;
@@ -425,7 +428,7 @@ export function DatabaseManagementPage({
setTableNestedNavigationActive(false);
setTableNestedNavigationBack(null);
setScreen({ kind: "relationships", workspaceId: row.workspaceId });
}, []);
}, [t]);
const openOverview = useCallback((row: CatalogDatabase) => {
const nextDraft = draftFrom(row);
@@ -437,17 +440,17 @@ export function DatabaseManagementPage({
setPartialSecretFailure(null);
setTablesNavigationState({ dirty: false, busy: false });
setScreen({ kind: "view", workspaceId: row.workspaceId });
}, []);
}, [t]);
const changeField = useCallback((field: "databaseName" | "schema", value: string) => {
setDraft((current) => current ? { ...current, [field]: value } : current);
}, []);
}, [t]);
const changeTransport = useCallback((transport: DatabaseTransport) => {
setDraft((current) => current
? { ...current, binding: { ...current.binding, transport } }
: current);
}, []);
}, [t]);
const changeBinding = useCallback(<K extends keyof DatabaseBinding>(
key: K,
@@ -456,18 +459,18 @@ export function DatabaseManagementPage({
setDraft((current) => current
? { ...current, binding: { ...current.binding, [key]: value } }
: current);
}, []);
}, [t]);
const changeSecret = useCallback((name: CatalogSecretName, value: string) => {
setDraft((current) => current
? { ...current, secrets: { ...current.secrets, [name]: value } }
: current);
}, []);
}, [t]);
const markStale = useCallback(() => {
setStale(true);
setStaleBannerOpen(true);
}, []);
}, [t]);
const save = useCallback(async () => {
if (!activeRow || !formSource || !draft || !editable || !canManage || stale || busy) return;
@@ -489,7 +492,7 @@ export function DatabaseManagementPage({
const replacements = secretReplacements(draft);
if (Object.keys(replacements).length > 0) {
if (!canManageSecrets) throw new Error("Secret replacement is not permitted");
if (!canManageSecrets) throw new Error(t("Secret replacement is not permitted"));
try {
saved = await replaceCatalogDatabaseSecrets(
saved.id ?? formSource.id!,
@@ -507,7 +510,7 @@ export function DatabaseManagementPage({
} else {
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
}
toast.warning("Database configuration saved, but secrets still need attention");
toast.warning(t("Database configuration saved, but secrets still need attention"));
return;
}
}
@@ -520,12 +523,12 @@ export function DatabaseManagementPage({
setStale(false);
setPartialSecretFailure(null);
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
toast.success(activeRow.configured ? "Database configuration saved" : "Database configuration added");
toast.success(activeRow.configured ? t("Database configuration saved") : t("Database configuration added"));
} catch (error) {
if (isStaleError(error)) {
markStale();
} else {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
}
} finally {
setBusyAction(null);
@@ -543,7 +546,7 @@ export function DatabaseManagementPage({
markStale,
queryClient,
stale,
]);
, t]);
const retrySecrets = useCallback(async () => {
if (!activeRow?.configured || !formSource?.id || !draft || !canManageSecrets || busy || stale) return;
@@ -561,15 +564,15 @@ export function DatabaseManagementPage({
setDraft({ ...draft, secrets: {} });
setPartialSecretFailure(null);
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
toast.success("Database secrets saved");
toast.success(t("Database secrets saved"));
} catch (error) {
setPartialSecretFailure(apiErrorMessage(error));
if (isStaleError(error)) markStale();
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setBusyAction(null);
}
}, [activeRow, busy, cacheSavedRow, canManageSecrets, draft, formSource, markStale, queryClient, stale]);
}, [activeRow, busy, cacheSavedRow, canManageSecrets, draft, formSource, markStale, queryClient, stale, t]);
const testConnection = useCallback(async () => {
if (
@@ -597,13 +600,13 @@ export function DatabaseManagementPage({
setConnectionTestResult({
outcome: "success",
title: "Connection test successful",
description: `${databaseDisplayName(tested)} is reachable.`,
description: adminMessage("{database} is reachable.", { database: databaseDisplayName(tested) }),
});
} else {
setConnectionTestResult({
outcome: "failure",
title: "Connection test failed",
description: `${databaseDisplayName(tested)}: ${tested.lastErrorMessage ?? "The database could not be reached."}`,
description: adminMessage("{database}: {diagnostic}", { database: databaseDisplayName(tested), diagnostic: { message: tested.lastErrorMessage ?? "The database could not be reached." } }),
});
}
} catch (error) {
@@ -616,7 +619,7 @@ export function DatabaseManagementPage({
} finally {
setBusyAction(null);
}
}, [activeRow, busy, cacheSavedRow, canManage, dirty, draft, formSource, markStale, queryClient, stale]);
}, [activeRow, busy, cacheSavedRow, canManage, dirty, draft, formSource, markStale, queryClient, stale, t]);
const remove = useCallback(async () => {
if (!activeRow?.configured || !formSource?.id || !canManage || busy || stale) return;
@@ -654,14 +657,14 @@ export function DatabaseManagementPage({
setBusyAction(null);
showList();
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
toast.success("Database configuration deleted");
toast.success(t("Database configuration deleted"));
} catch (error) {
if (isStaleError(error)) markStale();
else toast.error(apiErrorMessage(error));
else toast.error(t(apiErrorMessage(error)));
} finally {
setBusyAction(null);
}
}, [activeRow, busy, canManage, formSource, markStale, queryClient, showList, stale]);
}, [activeRow, busy, canManage, formSource, markStale, queryClient, showList, stale, t]);
const reloadLatest = useCallback(async () => {
if (busy || screen.kind === "list") return;
@@ -672,7 +675,7 @@ export function DatabaseManagementPage({
const latest = result.data?.find((row) => row.workspaceId === screen.workspaceId);
if (!latest) {
showList();
toast.info("This database configuration is no longer available");
toast.info(t("This database configuration is no longer available"));
return;
}
const nextDraft = draftFrom(latest);
@@ -684,16 +687,16 @@ export function DatabaseManagementPage({
setPartialSecretFailure(null);
if (!latest.configured && screen.kind === "delete") {
showList();
toast.info("This database configuration has already been deleted");
toast.info(t("This database configuration has already been deleted"));
} else if (screen.kind === "edit" && !latest.configured) {
setScreen({ kind: "configure", workspaceId: latest.workspaceId });
}
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setBusyAction(null);
}
}, [busy, refetch, screen, showList]);
}, [busy, refetch, screen, showList, t]);
const refreshList = useCallback(async () => {
if (isFetching) return;
@@ -702,9 +705,9 @@ export function DatabaseManagementPage({
if (result.error) throw result.error;
await invalidateCatalogMetrics();
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
}
}, [invalidateCatalogMetrics, isFetching, refetch]);
}, [invalidateCatalogMetrics, isFetching, refetch, t]);
const updateTrackedSyncRun = useCallback((run: CatalogSyncRun) => {
setActiveSyncRun(run);
@@ -713,13 +716,13 @@ export function DatabaseManagementPage({
? { ...row, activeSyncRun: ["queued", "running", "awaiting_confirmation", "applying"].includes(run.state) ? run : undefined }
: row
)));
}, [queryClient]);
}, [queryClient, t]);
const rememberSyncRun = useCallback((run: CatalogSyncRun) => {
updateTrackedSyncRun(run);
setSyncHistoryDatabaseId(run.databaseId);
setSyncDrawerOpen(true);
}, [updateTrackedSyncRun]);
}, [updateTrackedSyncRun, t]);
const rememberDescriptionGenerationRun = useCallback((run: DescriptionGenerationRun) => {
setActiveDescriptionGenerationRun(run);
@@ -728,7 +731,7 @@ export function DatabaseManagementPage({
(current = []) => [run, ...current.filter((item) => item.id !== run.id)],
);
setDescriptionGenerationDrawerOpen(true);
}, [queryClient]);
}, [queryClient, t]);
const openDescriptionGenerationHistory = useCallback(() => {
const scopedRuns = activeRow ? descriptionGenerationRuns.filter((item) => item.databaseId === activeRow.id) : descriptionGenerationRuns;
@@ -746,7 +749,7 @@ export function DatabaseManagementPage({
if (run) setActiveDescriptionGenerationRun(run);
setSensitivityAnalysisHistoryDrawerOpen(false);
setDescriptionGenerationDrawerOpen(true);
}, [activeDescriptionGenerationRun, activeRow, descriptionGenerationRuns, observedActiveDescriptionGenerationRun]);
}, [activeDescriptionGenerationRun, activeRow, descriptionGenerationRuns, observedActiveDescriptionGenerationRun, t]);
const openSensitivityReviewItemHistory = useCallback(() => {
const scopedRuns = activeRow ? sensitivityAnalysisRuns.filter((item) => item.databaseId === activeRow.id) : sensitivityAnalysisRuns;
@@ -764,7 +767,7 @@ export function DatabaseManagementPage({
if (run) setActiveSensitivityAnalysisRun(run);
setDescriptionGenerationDrawerOpen(false);
setSensitivityAnalysisHistoryDrawerOpen(true);
}, [activeRow, activeSensitivityAnalysisRun, observedActiveSensitivityAnalysisRun, sensitivityAnalysisRuns]);
}, [activeRow, activeSensitivityAnalysisRun, observedActiveSensitivityAnalysisRun, sensitivityAnalysisRuns, t]);
const descriptionGenerationTerminated = useCallback(async (run: DescriptionGenerationRun) => {
await Promise.all([
@@ -777,7 +780,7 @@ export function DatabaseManagementPage({
}),
invalidateCatalogMetrics(),
]);
}, [invalidateCatalogMetrics, queryClient]);
}, [invalidateCatalogMetrics, queryClient, t]);
const openSync = useCallback((row?: CatalogDatabase) => {
const databaseId = row?.id ?? activeSyncRun?.databaseId ?? activeRow?.id ?? null;
@@ -787,7 +790,7 @@ export function DatabaseManagementPage({
setActiveSyncRun(run);
setSyncHistoryDatabaseId(databaseId);
setSyncDrawerOpen(true);
}, [activeRow?.id, activeSyncRun]);
}, [activeRow?.id, activeSyncRun, t]);
const testSelected = useCallback(async (selected: CatalogDatabase[]) => {
setConnectionTestResult(null);
@@ -804,8 +807,8 @@ export function DatabaseManagementPage({
outcome: "success",
title: tested.length === 1 ? "Connection test successful" : "All connection tests successful",
description: tested.length === 1
? `${databaseDisplayName(tested[0])} is reachable.`
: `${tested.length} databases are reachable.`,
? adminMessage("{database} is reachable.", { database: databaseDisplayName(tested[0]) })
: adminMessage("{count} databases are reachable.", { count: tested.length }),
});
} else {
const succeeded = tested.length - failed.length;
@@ -813,10 +816,10 @@ export function DatabaseManagementPage({
outcome: "failure",
title: tested.length === 1
? "Connection test failed"
: `${failed.length} of ${tested.length} connection tests failed`,
: adminMessage("{count} of {total} connection tests failed", { count: failed.length, total: tested.length }),
description: tested.length === 1
? `${databaseDisplayName(failed[0])}: ${failed[0].lastErrorMessage ?? "The database could not be reached."}`
: `${succeeded} succeeded. Failed: ${failed.map(databaseDisplayName).join(", ")}.`,
? adminMessage("{database}: {diagnostic}", { database: databaseDisplayName(failed[0]), diagnostic: { message: failed[0].lastErrorMessage ?? "The database could not be reached." } })
: adminMessage("{succeeded} succeeded. Failed: {databases}.", { succeeded, databases: failed.map(databaseDisplayName).join(", ") }),
});
}
} catch (error) {
@@ -827,7 +830,7 @@ export function DatabaseManagementPage({
});
throw error;
}
}, [cacheSavedRow, queryClient]);
}, [cacheSavedRow, queryClient, t]);
const syncSelected = useCallback(async (selected: CatalogDatabase[], scope: CatalogSyncScope) => {
try {
@@ -837,12 +840,12 @@ export function DatabaseManagementPage({
return run ? { ...row, activeSyncRun: run } : row;
}));
if (runs[0]) rememberSyncRun(runs[0]);
toast.success(`${runs.length} schema synchronization${runs.length === 1 ? "" : "s"} started`);
toast.success((runs.length === 1 ? t("{count} schema synchronization started", { count: runs.length }) : t("{count} schema synchronizations started", { count: runs.length })));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
throw error;
}
}, [queryClient, rememberSyncRun]);
}, [queryClient, rememberSyncRun, t]);
const generateDatabaseDescriptions = useCallback(async (
selected: CatalogDatabase[],
@@ -857,12 +860,12 @@ export function DatabaseManagementPage({
scope,
);
rememberDescriptionGenerationRun(run);
toast.success(`${scope === "all" ? "Generate all descriptions" : "Generate missing descriptions"} started for ${database.workspaceName}`);
toast.success(t("{value} started for {workspaceName}", { value: scope === "all" ? t("Generate all descriptions") : t("Generate missing descriptions"), workspaceName: database.workspaceName }));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
throw error;
}
}, [rememberDescriptionGenerationRun, selectedMetadataModel]);
}, [rememberDescriptionGenerationRun, selectedMetadataModel, t]);
const consolidateDatabaseDescriptions = useCallback(async (
selected: CatalogDatabase[],
@@ -877,22 +880,22 @@ export function DatabaseManagementPage({
invalidateCatalogMetrics(),
]);
toast.success(
`Copied ${result.copied} description${result.copied === 1 ? "" : "s"}; skipped ${result.skipped}`,
(result.copied === 1 ? t("Copied {copied} description; skipped {skipped}", { copied: result.copied, skipped: result.skipped }) : t("Copied {copied} descriptions; skipped {skipped}", { copied: result.copied, skipped: result.skipped })),
);
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
throw error;
}
}, [invalidateCatalogMetrics, queryClient]);
}, [invalidateCatalogMetrics, queryClient, t]);
const requestSensitiveSuggestions = useCallback(async (
database: CatalogDatabase,
selection: SensitivityAnalysisRequest,
scopeLabel: string,
scopeLabel: AdminMessage,
) => {
if (!database.id) {
toast.error("The selected database is not configured, so sensitivity analysis was not started.");
throw new Error("database is not configured");
toast.error(t("The selected database is not configured, so sensitivity analysis was not started."));
throw new Error(t("database is not configured"));
}
setSensitiveReview(null);
setActiveSensitivityAnalysisRun(null);
@@ -910,35 +913,35 @@ export function DatabaseManagementPage({
}
setSensitivityAnalysisHistoryDrawerOpen(false);
setSensitiveReview({ databaseId: database.id, scopeLabel, suggestions: result.suggestions });
toast.success(`Prepared ${result.suggestions.length} local sensitivity assessment${result.suggestions.length === 1 ? "" : "s"} for review`);
toast.success((result.suggestions.length === 1 ? t("Prepared {count} local sensitivity assessment for review", { count: result.suggestions.length }) : t("Prepared {count} local sensitivity assessments for review", { count: result.suggestions.length })));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
throw error;
} finally {
await queryClient.invalidateQueries({ queryKey: SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY });
setSensitivityAnalysisStarting(false);
}
}, [queryClient]);
}, [queryClient, t]);
const suggestDatabaseSensitiveFields = useCallback(async (selected: CatalogDatabase[]) => {
if (selected.length !== 1) {
toast.error("Sensitivity analysis can run for only one database at a time. Select one database and try again.");
throw new Error("more than one database selected");
toast.error(t("Sensitivity analysis can run for only one database at a time. Select one database and try again."));
throw new Error(t("more than one database selected"));
}
const database = selected[0]!;
await requestSensitiveSuggestions(database, { scope: "all" }, `Entire database ${database.workspaceName}`);
}, [requestSensitiveSuggestions]);
await requestSensitiveSuggestions(database, { scope: "all" }, adminMessage("Entire database {workspaceName}", { workspaceName: database.workspaceName }));
}, [requestSensitiveSuggestions, t]);
const suggestActiveDatabaseSensitiveFields = useCallback(async (
selection: SensitivityAnalysisRequest,
scopeLabel: string,
scopeLabel: AdminMessage,
) => {
if (!activeRow) {
toast.error("The database is no longer available, so sensitivity analysis was not started.");
throw new Error("database is no longer available");
toast.error(t("The database is no longer available, so sensitivity analysis was not started."));
throw new Error(t("database is no longer available"));
}
await requestSensitiveSuggestions(activeRow, selection, scopeLabel);
}, [activeRow, requestSensitiveSuggestions]);
}, [activeRow, requestSensitiveSuggestions, t]);
const sensitiveColumnsSaved = useCallback((columns: CatalogColumn[]) => {
const savedById = new Map(columns.map((column) => [column.id, column]));
@@ -961,7 +964,7 @@ export function DatabaseManagementPage({
invalidateCatalogMetrics(),
]);
}
}, [invalidateCatalogMetrics, queryClient, sensitiveReview?.databaseId]);
}, [invalidateCatalogMetrics, queryClient, sensitiveReview?.databaseId, t]);
const deleteSelectedMetadata = useCallback(async (
selected: CatalogDatabase[],
@@ -980,26 +983,26 @@ export function DatabaseManagementPage({
invalidateCatalogMetrics(),
]);
toast.success(target === "tables"
? `Cleared ${counts.tables} catalog tables, ${counts.columns} columns, and ${counts.relationships} relationships. The source database was not changed.`
: `Cleared ${counts.relationships} catalog relationships. The source database was not changed.`);
? t("Cleared {tables} catalog tables, {columns} columns, and {relationships} relationships. The source database was not changed.", { tables: counts.tables, columns: counts.columns, relationships: counts.relationships })
: t("Cleared {relationships} catalog relationships. The source database was not changed.", { relationships: counts.relationships }));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
throw error;
}
}, [invalidateCatalogMetrics, queryClient]);
}, [invalidateCatalogMetrics, queryClient, t]);
const clearActiveCatalogTables = useCallback(async () => {
if (!activeRow?.id) return;
await deleteSelectedMetadata([activeRow], "tables");
}, [activeRow, deleteSelectedMetadata]);
}, [activeRow, deleteSelectedMetadata, t]);
const syncDatabase = useCallback(async (scope: CatalogSyncScope) => {
if (!activeRow?.id || !activeRow.configured) return;
try {
rememberSyncRun(await startCatalogSync(activeRow.id, activeRow.version, scope));
toast.success(SYNC_STARTED_MESSAGES[scope]);
} catch (error) { toast.error(apiErrorMessage(error)); }
}, [activeRow, rememberSyncRun]);
toast.success(t(SYNC_STARTED_MESSAGES[scope]));
} catch (error) { toast.error(t(apiErrorMessage(error))); }
}, [activeRow, rememberSyncRun, t]);
const catalogChanged = useCallback(async (run: CatalogSyncRun) => {
const databaseId = run.databaseId;
@@ -1013,7 +1016,7 @@ export function DatabaseManagementPage({
queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY }),
invalidateCatalogMetrics(),
]);
}, [invalidateCatalogMetrics, queryClient]);
}, [invalidateCatalogMetrics, queryClient, t]);
const formVisible = screen.kind !== "list" && screen.kind !== "tables" && screen.kind !== "relationships" && activeRow && draft;
const tablesVisible = screen.kind === "tables" && activeRow?.configured && activeRow.id;
@@ -1045,28 +1048,28 @@ export function DatabaseManagementPage({
const metrics = catalogMetricsQuery.data;
const metricsUnavailable = catalogMetricsQuery.isError;
const metricsValue = (value?: number) => (
catalogMetricsQuery.isLoading ? "…" : metricsUnavailable || value === undefined ? "—" : value.toLocaleString("en-US")
catalogMetricsQuery.isLoading ? "…" : metricsUnavailable || value === undefined ? "—" : value.toLocaleString(getLocale())
);
const latestCatalogUpdate = metrics?.updatedAt
? new Date(metrics.updatedAt).toLocaleString()
: "No synchronized metadata";
? new Date(metrics.updatedAt).toLocaleString(getLocale())
: t("No synchronized metadata");
const operationLabel = currentActiveRun
? `Synchronization: ${currentActiveRun.phase.replaceAll("_", " ")}`
: descriptionGenerationActive
? "Description generation active"
? t("Description generation active")
: observedActiveSensitivityAnalysisRun
? "Sensitive analysis active"
: "Idle";
? t("Sensitive analysis active")
: t("Idle");
const operationTone = currentActiveRun || descriptionGenerationActive || observedActiveSensitivityAnalysisRun
? "warning" as const
: "neutral" as const;
const fleetBreadcrumb = screen.kind === "tables" || screen.kind === "relationships"
? [
{ id: "databases", label: "Databases", onSelect: showList },
{ id: "database", label: activeRow?.workspaceName ?? "Database" },
{ id: screen.kind, label: screen.kind === "tables" ? "Tables" : "Relationships", current: true },
{ id: "databases", label: t("Databases"), onSelect: showList },
{ id: "database", label: activeRow?.workspaceName ?? t("Database") },
{ id: screen.kind, label: screen.kind === "tables" ? t("Tables") : t("Relationships"), current: true },
]
: [{ id: "databases", label: "Databases", current: true }];
: [{ id: "databases", label: t("Databases"), current: true }];
const connectionTestResultDialog = (
<Dialog
open={Boolean(connectionTestResult)}
@@ -1089,15 +1092,15 @@ export function DatabaseManagementPage({
: <CircleAlert className="size-5" />}
</span>
<div className="min-w-0 pt-0.5">
<DialogTitle>{connectionTestResult?.title ?? "Connection test"}</DialogTitle>
<DialogTitle>{renderAdminMessage(t, connectionTestResult?.title ?? "Connection test")}</DialogTitle>
<DialogDescription className="mt-2 leading-6 text-foreground">
{connectionTestResult?.description}
{connectionTestResult && renderAdminMessage(t, connectionTestResult.description)}
</DialogDescription>
</div>
</div>
</DialogHeader>
<DialogFooter>
<Button type="button" onClick={() => setConnectionTestResult(null)}>Done</Button>
<Button type="button" onClick={() => setConnectionTestResult(null)}>{t("Done")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1149,12 +1152,12 @@ export function DatabaseManagementPage({
const fleetFormMode = formVisible ? screen.kind as DatabaseFormMode : null;
const fleetDrawer = formVisible && fleetFormMode ? (
<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>
<nav aria-label={t("Database workspaces")} className="thot-administration-index p-4">
<p className="thot-administration-eyebrow mb-3">{t("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;
if (dirty && !window.confirm(t("Discard unsaved database changes?"))) return;
openForm(row.configured ? "edit" : "configure", row, event.currentTarget);
}}>{databaseDisplayName(row)}</Button>)}
</nav>
@@ -1233,11 +1236,10 @@ export function DatabaseManagementPage({
) : isError && rows.length === 0 ? (
<div className="grid h-full place-items-center p-6">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm" role="alert">
<p className="font-semibold text-destructive">The database catalog is unavailable.</p>
<p className="mt-1 leading-5 text-muted-foreground">Verify the catalog service and database migrations, then try again.</p>
<p className="font-semibold text-destructive">{t("The database catalog is unavailable.")}</p>
<p className="mt-1 leading-5 text-muted-foreground">{t("Verify the catalog service and database migrations, then try again.")}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refreshList()}>
<RefreshCw /> Try again
</Button>
<RefreshCw /> {t("Try again")}</Button>
</div>
</div>
) : (
@@ -1272,24 +1274,24 @@ export function DatabaseManagementPage({
return (
<>
<main aria-label="Database management" className="thot-administration-page thot-administration-database">
<main aria-label={t("Database management")} className="thot-administration-page thot-administration-database">
<FleetLedgerShell
header={(
<FleetLedgerHeader
title="Database management"
title={t("Database management")}
status={(
<FleetLedgerRealStatus
title="Catalog status"
title={t("Catalog status")}
items={[
{
id: "catalog",
label: "Catalog",
value: isLoading ? "Loading" : isError ? "Unavailable" : "Available",
detail: isError ? "Retry from the current grid" : `${rows.length} workspace${rows.length === 1 ? "" : "s"}`,
label: t("Catalog"),
value: isLoading ? t("Loading") : isError ? t("Unavailable") : t("Available"),
detail: isError ? t("Retry from the current grid") : rows.length === 1 ? t("{count} workspace", { count: rows.length }) : t("{count} workspaces", { count: rows.length }),
tone: isError ? "danger" : isLoading ? "warning" : "success",
},
{ id: "operation", label: "Operations", value: operationLabel, tone: operationTone },
{ id: "updated", label: "Metadata updated", value: latestCatalogUpdate, tone: "info" },
{ id: "operation", label: t("Operations"), value: operationLabel, tone: operationTone },
{ id: "updated", label: t("Metadata updated"), value: latestCatalogUpdate, tone: "info" },
]}
/>
)}
@@ -1301,39 +1303,39 @@ export function DatabaseManagementPage({
variant="outline"
size="sm"
className="whitespace-nowrap"
aria-label="View schema synchronization logs"
title="View schema synchronization progress and history"
aria-label={t("View schema synchronization logs")}
title={t("View schema synchronization progress and history")}
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"}
{currentActiveRun ? t("Schema sync active") : t("Schema sync logs")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
aria-label="View AI description generation logs"
title="View AI description generation progress and history"
aria-label={t("View AI description generation logs")}
title={t("View AI description generation progress and history")}
disabled={!canManage}
onClick={openDescriptionGenerationHistory}
>
<History aria-hidden="true" />
{descriptionGenerationActive ? "AI descriptions active" : "AI description logs"}
{descriptionGenerationActive ? t("AI descriptions active") : t("AI description logs")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
aria-label="View sensitive analysis progress"
title="View sensitive analysis progress and history"
aria-label={t("View sensitive analysis progress")}
title={t("View sensitive analysis progress and history")}
disabled={!canManage}
onClick={openSensitivityReviewItemHistory}
>
<History aria-hidden="true" />
{observedActiveSensitivityAnalysisRun ? "Sensitive analysis active" : "Sensitive analysis history"}
{observedActiveSensitivityAnalysisRun ? t("Sensitive analysis active") : t("Sensitive analysis history")}
</Button>
</>
)}
@@ -1341,13 +1343,13 @@ export function DatabaseManagementPage({
)}
kpis={(
<FleetLedgerKpiStrip
title={metricsDatabaseId ? "Database summary" : "Fleet summary"}
title={metricsDatabaseId ? t("Database summary") : t("Fleet summary")}
items={[
{ id: "tables", label: "Tables", value: metricsValue(metrics?.tables) },
{ id: "columns", label: "Columns", value: metricsValue(metrics?.columns) },
{ id: "sensitive", label: "Sensitive", value: metricsValue(metrics?.sensitiveColumns) },
{ id: "relationships", label: "Relationships", value: metricsValue(metrics?.relationships) },
{ id: "coverage", label: "Description coverage", value: metrics ? `${metrics.descriptionCoverage}%` : metricsValue() },
{ id: "tables", label: t("Tables"), value: metricsValue(metrics?.tables) },
{ id: "columns", label: t("Columns"), value: metricsValue(metrics?.columns) },
{ id: "sensitive", label: t("Sensitive"), value: metricsValue(metrics?.sensitiveColumns) },
{ id: "relationships", label: t("Relationships"), value: metricsValue(metrics?.relationships) },
{ id: "coverage", label: t("Description coverage"), value: metrics ? `${metrics.descriptionCoverage}%` : metricsValue() },
]}
/>
)}
@@ -1355,15 +1357,15 @@ export function DatabaseManagementPage({
<FleetLedgerBreadcrumb
items={fleetBreadcrumb}
back={screen.kind === "relationships"
? { label: "Back to databases", onBack: showList, disabled: navigationBusy }
? { label: t("Back to databases"), onBack: showList, disabled: navigationBusy }
: screen.kind === "tables" && tableNestedNavigationActive
? {
label: "Back to tables",
label: t("Back to tables"),
onBack: () => tableNestedNavigationBack?.(),
disabled: navigationBusy || !tableNestedNavigationBack,
}
: screen.kind === "tables"
? { label: "Back to databases", onBack: showList, disabled: navigationBusy }
? { label: t("Back to databases"), onBack: showList, disabled: navigationBusy }
: undefined}
/>
)}
@@ -1382,23 +1384,22 @@ export function DatabaseManagementPage({
}
return (
<main aria-label="Database management" className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<main aria-label={t("Database management")} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<header className="grid gap-4 border-b border-border bg-card px-4 py-4 sm:px-5">
<div>
<p className="thot-label mb-1">Administration</p>
<h1 className="font-heading text-xl font-semibold tracking-tight">Database management</h1>
<p className="thot-label mb-1">{t("Administration")}</p>
<h1 className="font-heading text-xl font-semibold tracking-tight">{t("Database management")}</h1>
<p className="mt-1 text-sm text-muted-foreground">
One database configuration for each repository workspace.
</p>
{t("One database configuration for each repository workspace.")}</p>
</div>
<div
role="group"
aria-label="Database management controls"
aria-label={t("Database management controls")}
className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
>
<div
role="group"
aria-label="Metadata description controls"
aria-label={t("Metadata description controls")}
className="flex flex-wrap items-end gap-3"
>
<MetadataGenerationModelSelector
@@ -1410,25 +1411,22 @@ export function DatabaseManagementPage({
onSelectedModelChange={setSelectedMetadataModel}
/>
{screen.kind === "list" ? <>
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="View description generation history" disabled={!canManage} title="View description generation history" onClick={openDescriptionGenerationHistory}>
<History /> View description generation history
</Button>
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="View sensitivity analysis history" disabled={!canManage} title={observedActiveSensitivityAnalysisRun ? "Sensitivity analysis is active" : "View sensitivity analysis history"} onClick={openSensitivityReviewItemHistory}>
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label={t("View description generation history")} disabled={!canManage} title={t("View description generation history")} onClick={openDescriptionGenerationHistory}>
<History /> {t("View description generation history")}</Button>
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label={t("View sensitivity analysis history")} disabled={!canManage} title={observedActiveSensitivityAnalysisRun ? t("Sensitivity analysis is active") : t("View sensitivity analysis history")} onClick={openSensitivityReviewItemHistory}>
<History />
{observedActiveSensitivityAnalysisRun ? <span aria-hidden="true" className="size-2 rounded-full bg-primary" /> : null}
View sensitivity analysis history
</Button>
{t("View sensitivity analysis history")}</Button>
</> : null}
</div>
{screen.kind === "list" ? (
<div
role="group"
aria-label="Database management actions"
aria-label={t("Database management actions")}
className="flex flex-wrap items-center gap-2 sm:justify-end"
>
<Button type="button" variant="outline" disabled={isFetching} onClick={() => void refreshList()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> Refresh
</Button>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> {t("Refresh")}</Button>
</div>
) : null}
</div>
@@ -1445,11 +1443,10 @@ export function DatabaseManagementPage({
{isError && rows.length === 0 ? (
<div className="grid flex-1 place-items-center p-6">
<div className="max-w-lg rounded-md border border-destructive/30 bg-destructive/8 p-5 text-sm" role="alert">
<p className="font-semibold text-destructive">The database catalog is unavailable.</p>
<p className="mt-1 leading-5 text-muted-foreground">Verify the catalog service and database migrations, then try again.</p>
<p className="font-semibold text-destructive">{t("The database catalog is unavailable.")}</p>
<p className="mt-1 leading-5 text-muted-foreground">{t("Verify the catalog service and database migrations, then try again.")}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refreshList()}>
<RefreshCw /> Try again
</Button>
<RefreshCw /> {t("Try again")}</Button>
</div>
</div>
) : (
+7 -5
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
import { Button } from "../components/ui/button";
@@ -11,20 +12,21 @@ interface Props {
}
export function DeleteConfirmDialog({ open, labels, label, onOpenChange, onConfirm }: Props) {
const { t: translate } = useI18n();
const targets = labels ?? (label ? [label] : []);
const description = targets.length > 1
? `${targets.length} sessions will be permanently deleted, including all of their documents. This action cannot be undone.`
: `"${targets[0]}" will be permanently deleted, including all of its documents. This action cannot be undone.`;
? translate("{count} sessions will be permanently deleted, including all of their documents. This action cannot be undone.", { count: targets.length })
: translate('"{name}" will be permanently deleted, including all of its documents. This action cannot be undone.', { name: targets[0] ?? "" });
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>Delete permanently</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>{translate("Delete permanently")}</DialogTitle></DialogHeader>
<DialogDescription>
{description}
</DialogDescription>
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>Delete</Button>
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>{translate("Cancel")}</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>{translate("Delete")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+4 -2
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useEffect, useRef, useState } from "react";
function fmt(ms: number): string {
@@ -13,6 +14,7 @@ function fmt(ms: number): string {
* restarts the count.
*/
export function ElapsedTimer({ running }: { running: boolean }) {
const { t: translate } = useI18n();
const [accumMs, setAccumMs] = useState(0);
const runStartRef = useRef<number | null>(null);
const [, tick] = useState(0);
@@ -37,8 +39,8 @@ export function ElapsedTimer({ running }: { running: boolean }) {
return (
<span
className="shrink-0 font-mono tabular-nums text-xs text-muted-foreground"
aria-label="Processing time"
title="Processing time (excludes time spent waiting on the reviewer)"
aria-label={translate("Processing time")}
title={translate("Processing time (excludes time spent waiting on the reviewer)")}
>
{fmt(accumMs + live)}
</span>
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, within } from "@testing-library/react";
import { act, render, screen, within } from "@testing-library/react";
import { setLocale } from "../i18n";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
@@ -22,6 +23,21 @@ beforeEach(() => server.use(
http.get("/api/workspaces/sales/evidence/evidence:order-key", () => HttpResponse.json(page)),
));
async function choose() { await screen.findByRole("option", { name: "Sales" }); await userEvent.selectOptions(screen.getByLabelText("Workspace"), "sales"); }
test("switches Evidence controls and detail labels without translating archive content", async () => {
renderPage(); await choose();
await userEvent.click(await screen.findByRole("button", { name: "Order key" }));
await screen.findByText("Join by order number, year and company.");
try {
act(() => setLocale("it"));
expect(screen.getByLabelText("Cerca Evidence")).toBeVisible();
expect(screen.getByRole("button", { name: "Aggiorna file" })).toBeVisible();
expect(screen.getByRole("heading", { name: "Provenienza" })).toBeVisible();
expect(screen.getByText("Join by order number, year and company.")).toBeVisible();
act(() => setLocale("en"));
expect(screen.getByLabelText("Search Evidence")).toBeVisible();
expect(screen.getByRole("button", { name: "Refresh files" })).toBeVisible();
} finally { act(() => setLocale("en")); }
});
test("reads complete content, manual lineage and actual host file path", async () => {
renderPage(); await choose(); await userEvent.click(await screen.findByRole("button", { name: "Order key" }));
expect(await screen.findByText("Join by order number, year and company.")).toBeVisible();
+56 -52
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiFetch, apiErrorMessage } from "../api/client";
@@ -24,38 +25,40 @@ const templates: Record<string, string> = {
};
function CopyText({ text, label, showText = true }: { text: string; label: string; showText?: boolean }) {
const { t } = useI18n();
const [notice, setNotice] = useState("");
return <div className="flex min-w-0 flex-wrap items-start gap-2">
{showText && <code className="min-w-0 flex-1 break-all text-xs select-text">{text}</code>}
<Button size="sm" variant="outline" aria-label={`Copy ${label}`} onClick={async () => {
<Button size="sm" variant="outline" aria-label={t("Copy {label}", { label: label })} onClick={async () => {
try { await navigator.clipboard.writeText(text); setNotice("Copied"); }
catch { setNotice("Select the text and copy it manually."); }
}}>Copy</Button><span role="status" className="text-xs text-muted-foreground">{notice}</span>
}}>{t("Copy")}</Button><span role="status" className="text-xs text-muted-foreground">{t(notice)}</span>
</div>;
}
export function EvidenceManagementPage({ canManage, initialWorkspaceId }: { canManage: boolean; initialWorkspaceId?: string }) {
const { t } = useI18n();
const context = useWorkingContextScope();
const generation = useAuthGeneration();
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="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>
if (!canManage) return <main className="p-6">{t("Evidence administration is not permitted.")}</main>;
return <main aria-label={t("Evidence management")} className="thot-administration-page">
<AdministrationHeader title={t("Evidence management")} description={t("Review workspace knowledge and maintain its Markdown files.")} actions={!context &&
<label className="grid min-w-52 gap-1 text-sm">{t("Workspace")}<select className={control} value={workspace} onChange={event => setWorkspace(event.target.value)}>
<option value="">{t("Choose a workspace")}</option>
{workspaces.data?.map(w => <option key={w.id} value={w.id}>{w.displayName ?? w.id}</option>)}
</select></label>
} />
{workspaces.isError && <p role="alert" className="p-6">{apiErrorMessage(workspaces.error)}</p>}
{workspaces.isError && <p role="alert" className="p-6">{t(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>}
: <p className="p-8 text-muted-foreground">{t("Choose a workspace to browse its Evidence and locate the files to edit.")}</p>}
</main>;
}
function WorkspaceEvidence({ workspace, generation }: { workspace: string; generation: number }) {
const { t } = useI18n();
const [filters, setFilters] = useState<Record<string, string>>({ q: "", kind: "", purpose: "", status: "", sort: "title", direction: "asc" });
const [page, setPage] = useState(1);
const [selected, setSelected] = useState("");
@@ -66,75 +69,76 @@ function WorkspaceEvidence({ workspace, generation }: { workspace: string; gener
{list.data && <Instructions data={list.data} />}
<EvidenceSources workspace={workspace} sources={list.data?.source_reviews ?? []} />
<div className="flex flex-wrap items-end gap-3">
<label className="grid flex-1 gap-1 text-sm">Search Evidence<input className={control} value={filters.q} onChange={e => filter("q", e.target.value)} placeholder="Title, identifier or content" /></label>
<Select label="Kind" value={filters.kind} values={kinds} onChange={v => filter("kind", v)} />
<Select label="Purpose" value={filters.purpose} values={["disambiguation", "rewriting", "schema_linking", "sql_generation"]} onChange={v => filter("purpose", v)} />
<Select label="Status" value={filters.status} values={Object.keys(states)} labels={states} onChange={v => filter("status", v)} />
<Button variant="outline" disabled={list.isFetching || detail.isFetching} onClick={() => { void list.refetch(); if (selected) void detail.refetch(); }}>Refresh files</Button>
<label className="grid flex-1 gap-1 text-sm">{t("Search Evidence")}<input className={control} value={filters.q} onChange={e => filter("q", e.target.value)} placeholder={t("Title, identifier or content")} /></label>
<Select label={t("Kind")} value={filters.kind} values={kinds} onChange={v => filter("kind", v)} />
<Select label={t("Purpose")} value={filters.purpose} values={["disambiguation", "rewriting", "schema_linking", "sql_generation"]} onChange={v => filter("purpose", v)} />
<Select label={t("Status")} value={filters.status} values={Object.keys(states)} labels={states} onChange={v => filter("status", v)} />
<Button variant="outline" disabled={list.isFetching || detail.isFetching} onClick={() => { void list.refetch(); if (selected) void detail.refetch(); }}>{t("Refresh files")}</Button>
</div>
<details><summary className="cursor-pointer text-sm font-medium">More filters and sorting</summary>
<div className="mt-3 flex flex-wrap gap-3">{["concept", "table", "column", "source"].map(key => <label key={key} className="grid gap-1 text-sm capitalize">{key}<input className={control} value={filters[key] ?? ""} onChange={e => filter(key, e.target.value)} /></label>)}
<Select label="Sort by" value={filters.sort} values={["title", "id", "kind", "status"]} onChange={v => filter("sort", v)} all={false} />
<Select label="Order" value={filters.direction} values={["asc", "desc"]} labels={{ asc: "Ascending", desc: "Descending" }} onChange={v => filter("direction", v)} all={false} />
<details><summary className="cursor-pointer text-sm font-medium">{t("More filters and sorting")}</summary>
<div className="mt-3 flex flex-wrap gap-3">{["concept", "table", "column", "source"].map(key => <label key={key} className="grid gap-1 text-sm capitalize">{t(key)}<input className={control} value={filters[key] ?? ""} onChange={e => filter(key, e.target.value)} /></label>)}
<Select label={t("Sort by")} value={filters.sort} values={["title", "id", "kind", "status"]} onChange={v => filter("sort", v)} all={false} />
<Select label={t("Order")} value={filters.direction} values={["asc", "desc"]} labels={{ asc: t("Ascending"), desc: t("Descending") }} onChange={v => filter("direction", v)} all={false} />
</div></details>
{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>)}
{list.isError && <p role="alert">{t(apiErrorMessage(list.error))}</p>}
{list.isPending && <p role="status">{t("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>{t(error.message)}</p><p>{t("Correct this file before consolidation.")}</p></div>)}
<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>
<section aria-label={t("Evidence list")} className="thot-administration-index p-4">
<h2 className="thot-administration-eyebrow mb-3">{t("Evidence library")}</h2>
<p className="mb-2 text-sm text-muted-foreground">{list.data?.total ?? 0} {t("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">{t("Evidence")}</th><th className="pr-3 font-medium">{t("Kind")}</th><th className="font-medium">{t("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">{t(item.kind)}</td><td className="align-top pt-3">{t(states[item.status])}</td></tr>)}</tbody>
</table></div>
{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>
{list.data?.total === 0 && <p className="py-6 text-sm text-muted-foreground">{t("No Evidence matches these filters. Clear the filters or use the file instructions to create a unit.")}</p>}
<nav aria-label={t("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)}>{t("Previous")}</Button><span className="text-sm">{t("Page")} {page}</span><Button variant="outline" size="sm" disabled={!list.data || page*25 >= list.data.total || list.isFetching} onClick={() => setPage(p => p+1)}>{t("Next")}</Button></nav>
</section>
<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 aria-label={t("Evidence detail")} className="thot-administration-detail">
{detail.isError ? <p role="alert">{t(apiErrorMessage(detail.error))}</p> : selected && detail.isPending ? <p role="status">{t("Loading Evidence…")}</p> : detail.data?.item ? <UnitDetail item={detail.data.item} location={detail.data.location} /> : <p className="py-10 text-sm text-muted-foreground">{t("Select an Evidence unit to read its complete content and provenance.")}</p>}
</section>
</div>
</div>;
}
function Select({ label, value, values, labels = {}, onChange, all = true }: { label: string; value: string; values: string[]; labels?: Record<string, string>; onChange: (value: string) => void; all?: boolean }) {
return <label className="grid gap-1 text-sm">{label}<select className={control} value={value} onChange={e => onChange(e.target.value)}>{all && <option value="">All</option>}{values.map(v => <option key={v} value={v}>{labels[v] ?? v.replaceAll("_", " ")}</option>)}</select></label>;
const { t } = useI18n();
return <label className="grid gap-1 text-sm">{t(label)}<select className={control} value={value} onChange={e => onChange(e.target.value)}>{all && <option value="">{t("All")}</option>}{values.map(v => <option key={v} value={v}>{t(labels[v] ?? v.replaceAll("_", " "))}</option>)}</select></label>;
}
function Instructions({ data }: { data: EvidencePage }) {
const { t } = useI18n();
const [kind, setKind] = useState("domain");
const example = `---\nschema_version: 4\nid: evidence:my-unit\nkind: ${kind}\nlanguage: en\npurposes: [sql_generation]\n---\n\n# My Evidence\n\n${templates[kind]}\n`;
return <div className="border-b pb-5">
<p className="mb-2 text-sm font-medium">{data.pending_revision ? "Consolidation is incomplete. The previous active content remains available." : data.active_revision ? "The core uses the last successfully consolidated version." : "Run the first consolidation to convert and activate the local archive."}</p>
{data.location.workspace ? <><p className="mb-1 text-xs text-muted-foreground">Workspace on the installation host</p><CopyText text={data.location.workspace} label="workspace path" /></> : <p role="alert" className="text-sm">A host file path is unavailable for this installation. Configure a persistent workspace-registry bind mount before editing files from the host.</p>}
<details className="mt-3"><summary className="cursor-pointer text-sm font-medium">Edit files, consolidate and commit</summary>
<p className="mb-2 text-sm font-medium">{data.pending_revision ? t("Consolidation is incomplete. The previous active content remains available.") : data.active_revision ? t("The core uses the last successfully consolidated version.") : t("Run the first consolidation to convert and activate the local archive.")}</p>
{data.location.workspace ? <><p className="mb-1 text-xs text-muted-foreground">{t("Workspace on the installation host")}</p><CopyText text={data.location.workspace} label={t("workspace path")} /></> : <p role="alert" className="text-sm">{t("A host file path is unavailable for this installation. Configure a persistent workspace-registry bind mount before editing files from the host.")}</p>}
<details className="mt-3"><summary className="cursor-pointer text-sm font-medium">{t("Edit files, consolidate and commit")}</summary>
<ol className="mt-3 max-w-[75ch] list-decimal space-y-3 pl-5 text-sm">
<li>Edit the files under <code>evidence/curated/&lt;kind&gt;/</code> with your preferred editor. Keep existing IDs. Remove a unit's file to delete it; keep the curated directory.</li>
<li>Check Git status and the diff. Saving a file does not update recall.
{data.location.git_commands.slice(0, 2).map(command => <div className="mt-2" key={command}><CopyText text={command} label="Git inspection command" /></div>)}</li>
<li>From the installation directory, run this command. It validates and activates Evidence without a full preprocessing run. If needed, add <code>--installation /absolute/path/thothii-installation.yaml</code> immediately after <code>tht</code>.
<div className="mt-2"><CopyText text={data.location.command} label="consolidation command" /></div></li>
<li>After success, review the generated metadata and complete Git commit/push manually. These commands use a POSIX shell on the installation host. A remote host's path is not a file on this browser's computer.
{data.location.git_commands.slice(2).map(command => <div className="mt-2" key={command}><CopyText text={command} label="Git publication command" /></div>)}</li>
<li>{t("Edit the files under")} <code>evidence/curated/&lt;kind&gt;/</code> {t("with your preferred editor. Keep existing IDs. Remove a unit's file to delete it; keep the curated directory.")}</li>
<li>{t("Check Git status and the diff. Saving a file does not update recall.")}{data.location.git_commands.slice(0, 2).map(command => <div className="mt-2" key={command}><CopyText text={command} label={t("Git inspection command")} /></div>)}</li>
<li>{t("From the installation directory, run this command. It validates and activates Evidence without a full preprocessing run. If needed, add")} <code>--installation /absolute/path/thothii-installation.yaml</code> {t("immediately after")} <code>tht</code>.
<div className="mt-2"><CopyText text={data.location.command} label={t("consolidation command")} /></div></li>
<li>{t("After success, review the generated metadata and complete Git commit/push manually. These commands use a POSIX shell on the installation host. A remote host's path is not a file on this browser's computer.")}{data.location.git_commands.slice(2).map(command => <div className="mt-2" key={command}><CopyText text={command} label={t("Git publication command")} /></div>)}</li>
</ol>
<p className="mt-3 text-sm text-muted-foreground">Source drafts are preserved under evidence/source/. Maintain the refined files under evidence/curated/. New source acquisition and refresh are not part of consolidation.</p>
<details className="mt-4"><summary className="cursor-pointer text-sm font-medium">Create a manual Evidence file</summary><div className="mt-3"><Select label="Example kind" value={kind} values={kinds} onChange={setKind} all={false} /><p className="my-2 text-sm">Save as <code>evidence/curated/{kind}/my-unit.md</code> with a unique ID. Replace the example with your domain knowledge. The system records manual provenance.</p><pre className="overflow-auto rounded-md bg-muted p-3 text-xs">{example}</pre><CopyText text={example} label="Markdown example" showText={false} /></div></details>
<p className="mt-3 text-sm text-muted-foreground">{t("Source drafts are preserved under evidence/source/. Maintain the refined files under evidence/curated/. New source acquisition and refresh are not part of consolidation.")}</p>
<details className="mt-4"><summary className="cursor-pointer text-sm font-medium">{t("Create a manual Evidence file")}</summary><div className="mt-3"><Select label={t("Example kind")} value={kind} values={kinds} onChange={setKind} all={false} /><p className="my-2 text-sm">{t("Save as")} <code>evidence/curated/{kind}/my-unit.md</code> {t("with a unique ID. Replace the example with your domain knowledge. The system records manual provenance.")}</p><pre className="overflow-auto rounded-md bg-muted p-3 text-xs">{example}</pre><CopyText text={example} label={t("Markdown example")} showText={false} /></div></details>
</details>
</div>;
}
function UnitDetail({ item, location }: { item: EvidenceUnit; location: EvidencePage["location"] }) {
const { t } = useI18n();
const source = item.provenance.kind === "manual" ? item.provenance.original : item.provenance;
const file = location.workspace ? `${location.workspace.replace(/\\/g, "/")}/evidence/${item.file}` : null;
return <article className="max-w-[75ch] space-y-5 pb-8">
<header><p className="text-xs text-muted-foreground">{states[item.status]} · {item.purposes.join(", ")}</p><h2 className="mt-1 font-serif text-xl font-semibold">{item.title}</h2></header>
{file && <CopyText text={file} label="Evidence file path" />}
{item.status !== "active" && <p className="text-sm text-muted-foreground">{["invalid", "removed"].includes(item.status) ? "The file is invalid or removed. Its last active content is shown here." : "This is the file version awaiting consolidation."} The core continues to use its last active version.</p>}
{Object.entries(item.applies_to).some(([, values]) => values.length > 0) && <section><h3 className="font-medium">Scope</h3>{Object.entries(item.applies_to).map(([name, values]) => values.length > 0 && <p className="mt-2 break-words text-sm" key={name}><span className="capitalize">{name}</span>: {values.join(", ")}</p>)}</section>}
{Object.entries(item.payload).map(([field, value]) => <section key={field}><h3 className="mb-2 text-sm font-semibold capitalize">{field.replaceAll("_", " ")}</h3>{typeof value === "string" ? field === "sql" ? <pre className="overflow-auto rounded-md bg-muted p-3 text-sm">{value}</pre> : <MarkdownView source={value} /> : Array.isArray(value) ? <ul className="list-inside list-disc text-sm">{value.map(v => <li key={v}>{v}</li>)}</ul> : <dl className="space-y-2 text-sm">{Object.entries(value).map(([key, text]) => <div key={key}><dt className="font-mono font-medium">{key || "(empty value)"}</dt><dd>{text}</dd></div>)}</dl>}</section>)}
<section className="border-t pt-4"><h3 className="font-medium">Provenance</h3><p className="mt-2 text-sm">{item.provenance.kind === "manual" ? `Manual declaration by ${item.provenance.declared_by}.` : "Derived from a source document."}</p>
{source?.source_file && <><p className="mt-2 break-all text-xs">{item.provenance.kind === "manual" ? "Original document (lineage): " : "Source: "}{source.source_file}</p>{source.supporting_excerpts?.map((text, i) => <p key={i} className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">“{text}”</p>)}</>}
<header><p className="text-xs text-muted-foreground">{t(states[item.status])} · {item.purposes.map(purpose => t(purpose.replaceAll("_", " "))).join(", ")}</p><h2 className="mt-1 font-serif text-xl font-semibold">{item.title}</h2></header>
{file && <CopyText text={file} label={t("Evidence file path")} />}
{item.status !== "active" && <p className="text-sm text-muted-foreground">{["invalid", "removed"].includes(item.status) ? t("The file is invalid or removed. Its last active content is shown here.") : t("This is the file version awaiting consolidation.")} {t("The core continues to use its last active version.")}</p>}
{Object.entries(item.applies_to).some(([, values]) => values.length > 0) && <section><h3 className="font-medium">{t("Scope")}</h3>{Object.entries(item.applies_to).map(([name, values]) => values.length > 0 && <p className="mt-2 break-words text-sm" key={name}><span className="capitalize">{t(name)}</span>: {values.join(", ")}</p>)}</section>}
{Object.entries(item.payload).map(([field, value]) => <section key={field}><h3 className="mb-2 text-sm font-semibold capitalize">{t(field.replaceAll("_", " "))}</h3>{typeof value === "string" ? field === "sql" ? <pre className="overflow-auto rounded-md bg-muted p-3 text-sm">{value}</pre> : <MarkdownView source={value} /> : Array.isArray(value) ? <ul className="list-inside list-disc text-sm">{value.map(v => <li key={v}>{v}</li>)}</ul> : <dl className="space-y-2 text-sm">{Object.entries(value).map(([key, text]) => <div key={key}><dt className="font-mono font-medium">{key || t("(empty value)")}</dt><dd>{text}</dd></div>)}</dl>}</section>)}
<section className="border-t pt-4"><h3 className="font-medium">{t("Provenance")}</h3><p className="mt-2 text-sm">{item.provenance.kind === "manual" ? t("Manual declaration by {author}.", { author: item.provenance.declared_by ?? "" }) : t("Derived from a source document.")}</p>
{source?.source_file && <><p className="mt-2 break-all text-xs">{item.provenance.kind === "manual" ? t("Original document (lineage): ") : t("Source: ")}{source.source_file}</p>{source.supporting_excerpts?.map((text, i) => <p key={i} className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">“{text}”</p>)}</>}
</section>
{item.review_items.length > 0 && <section><h3 className="font-medium">Review required</h3>{item.review_items.map((review, i) => <p key={i} className="mt-2 text-sm">{review.message}</p>)}</section>}
{item.review_items.length > 0 && <section><h3 className="font-medium">{t("Review required")}</h3>{item.review_items.map((review, i) => <p key={i} className="mt-2 text-sm">{t(review.message)}</p>)}</section>}
</article>;
}
+28 -25
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { actOnEvidenceSources, type EvidenceSourceAction, type EvidenceSourceReview, type EvidenceUnit } from "../api/evidence";
@@ -5,6 +6,7 @@ import { Button } from "../components/ui/button";
import { MarkdownView } from "../viewers/MarkdownView";
export function EvidenceSources({ workspace, sources }: {workspace: string; sources: EvidenceSourceReview[]}) {
const { t } = useI18n();
const client = useQueryClient();
const [notice, setNotice] = useState("");
const [showAll, setShowAll] = useState(false);
@@ -16,51 +18,52 @@ export function EvidenceSources({ workspace, sources }: {workspace: string; sour
const pending = sources.filter(s => s.status === "review" || s.status === "applying");
const settled = sources.filter(s => s.status !== "review" && s.status !== "applying");
const visible = showAll ? [...pending, ...settled] : pending;
return <section aria-label="Evidence sources" className="rounded-md border p-4">
return <section aria-label={t("Evidence sources")} className="rounded-md border p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div><h2 className="font-serif text-lg font-semibold">Sources and imports</h2>
<p className="mt-1 max-w-[75ch] text-sm text-muted-foreground">Place new Markdown drafts in the workspace’s <code>evidence/incoming/</code> folder. Import or refresh to acquire those drafts, original local documents and configured remote sources. Review changes before they enter recall.</p></div>
<div><h2 className="font-serif text-lg font-semibold">{t("Sources and imports")}</h2>
<p className="mt-1 max-w-[75ch] text-sm text-muted-foreground">{t("Place new Markdown drafts in the workspace’s")} <code>evidence/incoming/</code> {t("folder. Import or refresh to acquire those drafts, original local documents and configured remote sources. Review changes before they enter recall.")}</p></div>
<Button variant="outline" disabled={action.isPending || sources.some(s => s.status === "applying")}
onClick={() => {setNotice(""); action.mutate({action: "refresh"});}}>Import or refresh sources</Button>
onClick={() => {setNotice(""); action.mutate({action: "refresh"});}}>{t("Import or refresh sources")}</Button>
</div>
{action.isPending && <p role="status" className="mt-3 text-sm">{action.variables.action === "refresh" ? "Acquiring and refining sources. This may take a few minutes…" : "Saving the decision and updating the local index…"}</p>}
{action.isError && <p role="alert" className="mt-3 text-sm text-destructive">{action.error.message}</p>}
{notice && !action.isPending && <p role="status" className="mt-3 text-sm">{notice}</p>}
{pending.length > 0 && <p className="mt-4 text-sm font-medium">{pending.length} source comparison{pending.length === 1 ? "" : "s"} to review</p>}
{action.isPending && <p role="status" className="mt-3 text-sm">{action.variables.action === "refresh" ? t("Acquiring and refining sources. This may take a few minutes…") : t("Saving the decision and updating the local index…")}</p>}
{action.isError && <p role="alert" className="mt-3 text-sm text-destructive">{t(action.error.message)}</p>}
{notice && !action.isPending && <p role="status" className="mt-3 text-sm">{t(notice)}</p>}
{pending.length > 0 && <p className="mt-4 text-sm font-medium">{pending.length === 1 ? t("{count} source comparison to review", { count: pending.length }) : t("{count} source comparisons to review", { count: pending.length })}</p>}
{settled.length > 0 && <Button variant="ghost" size="sm" className="mt-3" aria-expanded={showAll} onClick={() => setShowAll(v => !v)}>
{showAll ? "Hide decided sources" : `Show ${settled.length} unchanged or decided sources`}</Button>}
{showAll ? t("Hide decided sources") : t("Show {count} unchanged or decided sources", { count: settled.length })}</Button>}
{visible.length > 0 && <div className="mt-3 space-y-3">{visible.map(source => <details key={source.id} className="rounded-md border p-3">
<summary className="cursor-pointer break-all text-sm"><span className="font-medium">{source.uri}</span><span className="ml-2 text-muted-foreground">{source.status === "review" ? "Review changes" : source.status === "applying" ? "Activation incomplete" : source.status === "kept" ? "Local curation retained" : "Source accepted"}</span></summary>
{source.availability === "missing" && <p className="mt-3 text-sm">This source was absent from the latest successful acquisition. Existing Evidence has been retained; absence does not delete it.</p>}
<summary className="cursor-pointer break-all text-sm"><span className="font-medium">{source.uri}</span><span className="ml-2 text-muted-foreground">{source.status === "review" ? t("Review changes") : source.status === "applying" ? t("Activation incomplete") : source.status === "kept" ? t("Local curation retained") : t("Source accepted")}</span></summary>
{source.availability === "missing" && <p className="mt-3 text-sm">{t("This source was absent from the latest successful acquisition. Existing Evidence has been retained; absence does not delete it.")}</p>}
{(source.status === "review" || source.status === "applying") && <>
<p className="mt-3 text-sm text-muted-foreground">The last active version remains in recall until a decision is successfully indexed. Keeping local content records a manual declaration; using the proposal adopts the acquired source.</p>
{source.suppressed && <p className="mt-2 text-sm">Previous deletions are protected. Newly generated units from this source have been suppressed.</p>}
<div className="mt-4 grid gap-5 lg:grid-cols-2"><div><h3 className="font-semibold">Current local Evidence</h3>
{(source.current?.length ?? 0) === 0 && <p className="mt-2 text-sm text-muted-foreground">No existing units.</p>}
<p className="mt-3 text-sm text-muted-foreground">{t("The last active version remains in recall until a decision is successfully indexed. Keeping local content records a manual declaration; using the proposal adopts the acquired source.")}</p>
{source.suppressed && <p className="mt-2 text-sm">{t("Previous deletions are protected. Newly generated units from this source have been suppressed.")}</p>}
<div className="mt-4 grid gap-5 lg:grid-cols-2"><div><h3 className="font-semibold">{t("Current local Evidence")}</h3>
{(source.current?.length ?? 0) === 0 && <p className="mt-2 text-sm text-muted-foreground">{t("No existing units.")}</p>}
{source.current?.map(unit => <SourceUnit key={unit.id} unit={unit} />)}</div>
<div><h3 className="font-semibold">Proposed Evidence</h3>
{source.proposed.length === 0 && <p className="mt-2 text-sm text-muted-foreground">No replacement units were proposed.</p>}
<div><h3 className="font-semibold">{t("Proposed Evidence")}</h3>
{source.proposed.length === 0 && <p className="mt-2 text-sm text-muted-foreground">{t("No replacement units were proposed.")}</p>}
{source.proposed.map(unit => <SourceUnit key={unit.id} unit={unit} />)}
{!!source.removed_ids?.length && <p className="mt-3 text-sm">Using this proposal will retire: {source.removed_ids.join(", ")}</p>}
{!!source.removed_ids?.length && <p className="mt-3 text-sm">{t("Using this proposal will retire:")} {source.removed_ids.join(", ")}</p>}
</div></div>
<div className="mt-4 flex flex-wrap gap-2">{(["keep", "replace"] as const).filter(d => source.status !== "applying" || d === source.decision).map(decision => <Button key={decision} variant={decision === "keep" ? "outline" : "default"}
disabled={action.isPending || (decision === "replace" && source.proposed.some(u => u.review_items.length > 0))}
onClick={() => {setNotice(""); action.mutate({action: "decide", sourceId: source.id, revision: source.revision, decision});}}>
{source.status === "applying" ? "Retry saved decision" : decision === "keep" ? "Keep local Evidence" : "Use proposed Evidence"}</Button>)}</div>
{source.status === "applying" ? t("Retry saved decision") : decision === "keep" ? t("Keep local Evidence") : t("Use proposed Evidence")}</Button>)}</div>
</>}
</details>)}</div>}
</section>;
}
function SourceUnit({ unit }: {unit: EvidenceUnit}) {
const { t } = useI18n();
const origin = unit.provenance.kind === "manual" ? unit.provenance.original : unit.provenance;
return <article className="mt-3 min-w-0 border-t pt-3">
<h4 className="font-medium">{unit.title}</h4><p className="mt-1 break-all text-xs text-muted-foreground">{unit.id} · {unit.kind} · {unit.purposes.join(", ")}</p>
{Object.entries(unit.applies_to).map(([key, values]) => values.length > 0 && <p key={key} className="mt-2 break-all text-sm">{key}: {values.join(", ")}</p>)}
{Object.entries(unit.payload).map(([key, value]) => <section key={key} className="mt-3"><h5 className="text-sm font-medium capitalize">{key.replaceAll("_", " ")}</h5>
{typeof value === "string" ? key === "sql" ? <pre className="overflow-auto rounded bg-muted p-2 text-sm">{value}</pre> : <MarkdownView source={value} /> : Array.isArray(value) ? <ul className="list-inside list-disc text-sm">{value.map(v => <li key={v}>{v}</li>)}</ul> : <dl className="text-sm">{Object.entries(value).map(([k,v]) => <div key={k}><dt className="font-mono">{k || "(empty value)"}</dt><dd>{v}</dd></div>)}</dl>}</section>)}
<p className="mt-3 text-xs text-muted-foreground">{unit.provenance.kind === "manual" ? `Manual declaration by ${unit.provenance.declared_by}. Original excerpts:` : "Source excerpts:"}</p>
<h4 className="font-medium">{unit.title}</h4><p className="mt-1 break-all text-xs text-muted-foreground">{unit.id} · {t(unit.kind)} · {unit.purposes.map(purpose => t(purpose.replaceAll("_", " "))).join(", ")}</p>
{Object.entries(unit.applies_to).map(([key, values]) => values.length > 0 && <p key={key} className="mt-2 break-all text-sm">{t(key)}: {values.join(", ")}</p>)}
{Object.entries(unit.payload).map(([key, value]) => <section key={key} className="mt-3"><h5 className="text-sm font-medium capitalize">{t(key.replaceAll("_", " "))}</h5>
{typeof value === "string" ? key === "sql" ? <pre className="overflow-auto rounded bg-muted p-2 text-sm">{value}</pre> : <MarkdownView source={value} /> : Array.isArray(value) ? <ul className="list-inside list-disc text-sm">{value.map(v => <li key={v}>{v}</li>)}</ul> : <dl className="text-sm">{Object.entries(value).map(([k,v]) => <div key={k}><dt className="font-mono">{k || t("(empty value)")}</dt><dd>{v}</dd></div>)}</dl>}</section>)}
<p className="mt-3 text-xs text-muted-foreground">{unit.provenance.kind === "manual" ? t("Manual declaration by {author}. Original excerpts:", { author: unit.provenance.declared_by ?? "" }) : t("Source excerpts:")}</p>
{origin?.supporting_excerpts?.map((excerpt, i) => <blockquote key={i} className="mt-2 border-l-2 pl-3 text-sm text-muted-foreground">{excerpt}</blockquote>)}
{unit.review_items.map((r,i) => <p key={i} className="mt-2 text-sm text-destructive">{r.message}</p>)}
{unit.review_items.map((r,i) => <p key={i} className="mt-2 text-sm text-destructive">{t(r.message)}</p>)}
</article>;
}
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, within } from "@testing-library/react";
import { act, render, screen, within } from "@testing-library/react";
import { setLocale } from "../i18n";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
@@ -30,6 +31,21 @@ async function chooseWorkspace() {
await userEvent.selectOptions(screen.getByLabelText("Workspace"), "sales");
}
test("switches Memory editor controls in place and preserves the draft", async () => {
renderPage(); await chooseWorkspace();
await userEvent.click(screen.getByRole("button", { name: "New card" }));
await userEvent.type(screen.getByLabelText("Title"), "Workspace content");
try {
act(() => setLocale("it"));
expect(screen.getByLabelText("Titolo")).toHaveValue("Workspace content");
expect(screen.getByRole("button", { name: "Salva scheda" })).toBeVisible();
expect(screen.getAllByRole("option", { name: "Chiarimento di dominio" })).toHaveLength(2);
act(() => setLocale("en"));
expect(screen.getByLabelText("Title")).toHaveValue("Workspace content");
expect(screen.getByRole("button", { name: "Save card" })).toBeVisible();
} finally { act(() => setLocale("en")); }
});
test("reads complete content and cancels unsaved edits", async () => {
renderPage(); await chooseWorkspace();
await userEvent.click(await screen.findByRole("button", { name: /Order Domain clarification/ }));
+83 -81
View File
@@ -1,3 +1,4 @@
import { getLocale, useI18n } from "../i18n";
import { useEffect, useState, type FormEvent, type InputHTMLAttributes } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "../components/ui/button";
@@ -17,7 +18,8 @@ const families: Record<MemoryFamily, string> = {
const fieldLabels = { detail: "Content", scope: "Scope", rationale: "Rationale",
question: "Question / context", sql: "Approved SQL" };
const control = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50";
function Input(props: InputHTMLAttributes<HTMLInputElement>) { return <input {...props} className={`${control} ${props.className ?? ""}`} />; }
function Input(props: InputHTMLAttributes<HTMLInputElement>) {
const { t } = useI18n(); return <input {...props} className={`${control} ${props.className ?? ""}`} />; }
const emptyCard = (): MemoryInput => ({ family: "domain_clarification", subject: "", detail: "",
scope: "", rationale: "", question: "", sql: "", concepts: [], dependencies: [], links: [] });
@@ -25,6 +27,7 @@ export function MemoryManagementPage({ canManage, onDirtyChange, onBusyChange, i
canManage: boolean; onDirtyChange: (dirty: boolean) => void; onBusyChange?: (busy: boolean) => void;
initialWorkspaceId?: string;
}) {
const { t } = useI18n();
const context = useWorkingContextScope();
const [workspace, setWorkspace] = useState(initialWorkspaceId ?? "");
const [dirty, setDirty] = useState(false);
@@ -34,26 +37,26 @@ export function MemoryManagementPage({ canManage, onDirtyChange, onBusyChange, i
queryFn: () => apiFetch<Array<{ id: string; displayName?: string }>>("/workspaces"), enabled: canManage });
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="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;
if (!canManage) return <main aria-label={t("Memory management")} className="p-6">{t("Memory administration is not permitted.")}</main>;
return <main aria-label={t("Memory management")} className="thot-administration-page">
<AdministrationHeader title={t("Memory management")} description={t("Curate reusable knowledge for each workspace.")} actions={!context &&
<label className="grid min-w-52 gap-1 text-sm font-medium">{t("Workspace")}<select aria-label={t("Workspace")} disabled={busy} className={control} value={workspace} onChange={event => {
if (dirty && !window.confirm(t("Discard unsaved Memory changes?"))) return;
setDirty(false); setWorkspace(event.target.value);
}}><option value="">Choose a workspace</option>
}}><option value="">{t("Choose a workspace")}</option>
{workspaces.data?.map(w => <option value={w.id} key={w.id}>{w.displayName ?? w.id}</option>)}
</select></label>
} />
{workspaces.isError && <p role="alert" className="p-6">{apiErrorMessage(workspaces.error)}</p>}
{workspaces.isError && <p role="alert" className="p-6">{t(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>}
: <p className="p-8 text-muted-foreground">{t("Choose a workspace to browse its knowledge or create a Memory card.")}</p>}
</main>;
}
function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
workspace: string; onDirtyChange: (dirty: boolean) => void; onBusyChange: (busy: boolean) => void;
}) {
const { t } = useI18n();
const queryClient = useQueryClient();
const [filters, setFilters] = useState<Record<string, string>>({ q: "", family: "", sort: "updated_at", direction: "desc" });
const [page, setPage] = useState(1);
@@ -73,7 +76,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
const dirty = draft !== null && (!selected || JSON.stringify(draft) !== JSON.stringify(memoryInput(selected)));
useEffect(() => { onDirtyChange(dirty); return () => onDirtyChange(false); }, [dirty, onDirtyChange]);
function leaveDraft() { return !dirty || window.confirm("Discard unsaved Memory changes?"); }
function leaveDraft() { return !dirty || window.confirm(t("Discard unsaved Memory changes?")); }
useEffect(() => { onBusyChange(busy); return () => onBusyChange(false); }, [busy, onBusyChange]);
function filter(key: string, value: string) { setFilters(old => ({ ...old, [key]: value })); setPage(1); }
function update<K extends keyof MemoryInput>(key: K, value: MemoryInput[K]) {
@@ -116,114 +119,113 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
return <>
<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)}>
<option value="">All families</option>{Object.entries(families).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
<label className="grid min-w-52 flex-1 gap-1 text-sm">{t("Search cards")}<Input value={filters.q} onChange={e => filter("q", e.target.value)} placeholder={t("Text or card ID")} /></label>
<label className="grid gap-1 text-sm">{t("Family")}<select aria-label={t("Filter by family")} className={control} value={filters.family} onChange={e => filter("family", e.target.value)}>
<option value="">{t("All families")}</option>{Object.entries(families).map(([k, v]) => <option key={k} value={k}>{t(v)}</option>)}
</select></label>
<label className="grid gap-1 text-sm">Sort by<select aria-label="Sort by" className={control} value={filters.sort} onChange={e => filter("sort", e.target.value)}>
<option value="updated_at">Updated</option><option value="created_at">Created</option><option value="subject">Title</option><option value="family">Family</option>
<label className="grid gap-1 text-sm">{t("Sort by")}<select aria-label={t("Sort by")} className={control} value={filters.sort} onChange={e => filter("sort", e.target.value)}>
<option value="updated_at">{t("Updated")}</option><option value="created_at">{t("Created")}</option><option value="subject">{t("Title")}</option><option value="family">{t("Family")}</option>
</select></label>
<Button variant="outline" onClick={() => filter("direction", filters.direction === "desc" ? "asc" : "desc")}>{filters.direction === "desc" ? "Descending" : "Ascending"}</Button>
<Button disabled={busy} onClick={() => { if (!leaveDraft()) return; setSelected(null); setDraft(emptyCard()); setError(""); setNotice(""); }}>New card</Button>
<Button variant="outline" onClick={() => filter("direction", filters.direction === "desc" ? "asc" : "desc")}>{filters.direction === "desc" ? t("Descending") : t("Ascending")}</Button>
<Button disabled={busy} onClick={() => { if (!leaveDraft()) return; setSelected(null); setDraft(emptyCard()); setError(""); setNotice(""); }}>{t("New card")}</Button>
</div>
<details className="shrink-0 border-b px-6 pb-4"><summary className="cursor-pointer text-sm font-medium">More filters</summary>
<details className="shrink-0 border-b px-6 pb-4"><summary className="cursor-pointer text-sm font-medium">{t("More filters")}</summary>
<div className="mt-3 grid grid-cols-2 gap-3 xl:grid-cols-4">
{(["concept", "database", "table", "column"] as const).map(key => <label className="grid gap-1 text-sm capitalize" key={key}>{key}
{(["concept", "database", "table", "column"] as const).map(key => <label className="grid gap-1 text-sm capitalize" key={key}>{t(key)}
<Input value={filters[key] ?? ""} onChange={e => filter(key, e.target.value)} /></label>)}
<label className="grid gap-1 text-sm">Origin<select aria-label="Origin" className={control} value={filters.origin ?? ""} onChange={e => filter("origin", e.target.value)}>
<option value="">All origins</option><option value="manual">Manual</option><option value="workflow">Workflow</option></select></label>
{(["updated_after", "updated_before"] as const).map(key => <label className="grid gap-1 text-sm" key={key}>{key === "updated_after" ? "Updated from" : "Updated until"}
<label className="grid gap-1 text-sm">{t("Origin")}<select aria-label={t("Origin")} className={control} value={filters.origin ?? ""} onChange={e => filter("origin", e.target.value)}>
<option value="">{t("All origins")}</option><option value="manual">{t("Manual")}</option><option value="workflow">{t("Workflow")}</option></select></label>
{(["updated_after", "updated_before"] as const).map(key => <label className="grid gap-1 text-sm" key={key}>{key === "updated_after" ? t("Updated from") : t("Updated until")}
<Input type="date" onChange={e => filter(key, e.target.value ? `${e.target.value}T${key === "updated_before" ? "23:59:59" : "00:00:00"}Z` : "")} /></label>)}
</div>
</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>}
{notice && <p role="status" className="border-b bg-muted/50 px-6 py-3 text-sm">{t(notice)}</p>}
{error && <p role="alert" className="px-6 py-3 text-sm text-destructive">{t(error)}</p>}
<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>}
<section aria-label={t("Memory cards")} className="thot-administration-index">
<h2 className="thot-administration-section-label">{t("Knowledge library")}</h2>
{list.isPending && <p role="status" className="p-6 text-sm">{t("Loading Memory cards…")}</p>}
{list.isError && <div role="alert" className="p-6"><p>{t(apiErrorMessage(list.error))}</p><Button variant="outline" onClick={() => void list.refetch()}>{t("Retry loading")}</Button></div>}
{list.data && <>
<p className="px-6 py-3 text-xs text-muted-foreground">{list.data.total} {list.data.total === 1 ? "card" : "cards"}</p>
{list.data.items.length === 0 && <p className="px-6 py-8 text-sm text-muted-foreground">No cards match these filters. Adjust the filters or create a card.</p>}
<p className="px-6 py-3 text-xs text-muted-foreground">{list.data.total === 1 ? t("{count} card", { count: list.data.total }) : t("{count} cards", { count: list.data.total })}</p>
{list.data.items.length === 0 && <p className="px-6 py-8 text-sm text-muted-foreground">{t("No cards match these filters. Adjust the filters or create a card.")}</p>}
<ul>{list.data.items.map(card => <li key={card.id} className="border-b">
<button disabled={busy} aria-current={selected?.id === card.id ? "true" : undefined} onClick={() => void open(card.id)}
className={`w-full px-6 py-4 text-left outline-none hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 ${selected?.id === card.id ? "bg-muted" : ""}`}>
<span className="block break-words text-sm font-semibold">{card.subject}</span>
<span className="mt-1 block text-xs text-muted-foreground">{families[card.family]} · {card.origin === "manual" ? "Manual" : "Workflow"}</span>
<span className="mt-2 block text-xs">{card.indexed ? "Index up to date" : "Index update pending"}</span>
<span className="mt-1 block text-xs text-muted-foreground">{t(families[card.family])} · {card.origin === "manual" ? t("Manual") : t("Workflow")}</span>
<span className="mt-2 block text-xs">{card.indexed ? t("Index up to date") : t("Index update pending")}</span>
</button></li>)}</ul>
<nav aria-label="Memory pagination" className="flex items-center justify-between gap-2 p-4">
<Button variant="outline" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Previous</Button>
<span className="text-xs">Page {page} of {Math.max(1, Math.ceil(list.data.total / 25))}</span>
<Button variant="outline" disabled={page * 25 >= list.data.total} onClick={() => setPage(p => p + 1)}>Next</Button>
<nav aria-label={t("Memory pagination")} className="flex items-center justify-between gap-2 p-4">
<Button variant="outline" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>{t("Previous")}</Button>
<span className="text-xs">{t("Page")} {page} {t("of")} {Math.max(1, Math.ceil(list.data.total / 25))}</span>
<Button variant="outline" disabled={page * 25 >= list.data.total} onClick={() => setPage(p => p + 1)}>{t("Next")}</Button>
</nav>
</>}
</section>
<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>}
<section aria-label={t("Memory detail")} className="thot-administration-detail">
{!draft && !selected && <p className="py-8 text-sm text-muted-foreground">{t("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>
<div><p className="text-xs text-muted-foreground">{t(families[selected.family])}</p><h2 className="mt-1 break-words font-serif text-xl font-semibold">{selected.subject}</h2></div>
<dl className="space-y-4 text-sm">
{(["detail", "scope", "rationale", "question", "sql"] as const).filter(k => selected[k]).map(key => <div key={key}>
<dt className="mb-1 font-semibold capitalize">{key === "detail" ? "Content" : key === "sql" ? "Approved SQL" : key}</dt>
<dt className="mb-1 font-semibold capitalize">{key === "detail" ? t("Content") : key === "sql" ? t("Approved SQL") : t(key)}</dt>
<dd className={`max-w-[72ch] whitespace-pre-wrap break-words ${key === "sql" ? "font-mono text-xs" : ""}`}>{selected[key]}</dd></div>)}
<div><dt className="font-semibold">Provenance</dt><dd>{selected.origin === "manual" ? "Created manually" : `Workflow session ${selected.session_id}`}</dd></div>
{selected.decision_seq !== null && <div><dt className="font-semibold">Source decision</dt><dd>{selected.decision_seq}</dd></div>}
<div><dt className="font-semibold">Card ID</dt><dd className="break-all font-mono text-xs">{selected.id}</dd></div>
<div><dt className="font-semibold">Updated</dt><dd>{new Date(selected.updated_at).toLocaleString()}</dd></div>
<div><dt className="font-semibold">Created</dt><dd>{new Date(selected.created_at).toLocaleString()}</dd></div>
{selected.concepts.length > 0 && <div><dt className="font-semibold">Concepts</dt><dd>{selected.concepts.join(", ")}</dd></div>}
{selected.dependencies.length > 0 && <div><dt className="font-semibold">Schema dependencies</dt><dd><ul>{selected.dependencies.map((d, i) => <li key={i}>{[d.database, d.schema_name, d.table, d.column].filter(Boolean).join(" / ")}</li>)}</ul></dd></div>}
{selected.links.length > 0 && <div><dt className="font-semibold">Linked cards</dt><dd><ul>{selected.links.map(link => <li key={link.target_id} className="my-2"><button className="text-left text-primary underline" onClick={() => void open(link.target_id)}>{link.meaning}</button></li>)}</ul></dd></div>}
<div><dt className="font-semibold">{t("Provenance")}</dt><dd>{selected.origin === "manual" ? t("Created manually") : t("Workflow session {session}", { session: selected.session_id ?? "" })}</dd></div>
{selected.decision_seq !== null && <div><dt className="font-semibold">{t("Source decision")}</dt><dd>{selected.decision_seq}</dd></div>}
<div><dt className="font-semibold">{t("Card ID")}</dt><dd className="break-all font-mono text-xs">{selected.id}</dd></div>
<div><dt className="font-semibold">{t("Updated")}</dt><dd>{new Date(selected.updated_at).toLocaleString(getLocale())}</dd></div>
<div><dt className="font-semibold">{t("Created")}</dt><dd>{new Date(selected.created_at).toLocaleString(getLocale())}</dd></div>
{selected.concepts.length > 0 && <div><dt className="font-semibold">{t("Concepts")}</dt><dd>{selected.concepts.join(", ")}</dd></div>}
{selected.dependencies.length > 0 && <div><dt className="font-semibold">{t("Schema dependencies")}</dt><dd><ul>{selected.dependencies.map((d, i) => <li key={i}>{[d.database, d.schema_name, d.table, d.column].filter(Boolean).join(" / ")}</li>)}</ul></dd></div>}
{selected.links.length > 0 && <div><dt className="font-semibold">{t("Linked cards")}</dt><dd><ul>{selected.links.map(link => <li key={link.target_id} className="my-2"><button className="text-left text-primary underline" onClick={() => void open(link.target_id)}>{link.meaning}</button></li>)}</ul></dd></div>}
</dl>
<div className="flex gap-2"><Button disabled={busy} onClick={() => setDraft(memoryInput(selected))}>Edit card</Button>
<Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(true)}>Delete card</Button></div>
{deleteConfirm && <div className="space-y-3 border-t pt-4"><p className="text-sm">Delete “{selected.subject}” and its links? Other cards will be kept.</p>
<div className="flex gap-2"><Button variant="destructive" disabled={busy} onClick={() => void mutate(() => deleteMemory(workspace, selected.id))}>Confirm deletion</Button>
<Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(false)}>Keep card</Button></div></div>}
<div className="flex gap-2"><Button disabled={busy} onClick={() => setDraft(memoryInput(selected))}>{t("Edit card")}</Button>
<Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(true)}>{t("Delete card")}</Button></div>
{deleteConfirm && <div className="space-y-3 border-t pt-4"><p className="text-sm">{t("Delete “")}{selected.subject}{t("” and its links? Other cards will be kept.")}</p>
<div className="flex gap-2"><Button variant="destructive" disabled={busy} onClick={() => void mutate(() => deleteMemory(workspace, selected.id))}>{t("Confirm deletion")}</Button>
<Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(false)}>{t("Keep card")}</Button></div></div>}
</div>}
{draft && <form onSubmit={save} className="space-y-5">
<h2 className="font-serif text-xl font-semibold">{selected ? "Edit card" : "New Memory card"}</h2>
<h2 className="font-serif text-xl font-semibold">{selected ? t("Edit card") : t("New Memory card")}</h2>
<fieldset disabled={busy} className="space-y-4">
<label className="grid gap-1 text-sm font-medium">Family<select aria-label="Family" className={control} value={draft.family} onChange={e => {
<label className="grid gap-1 text-sm font-medium">{t("Family")}<select aria-label={t("Family")} className={control} value={draft.family} onChange={e => {
update("family", e.target.value as MemoryFamily); if (e.target.value !== "solved_question") update("sql", "");
}}>{Object.entries(families).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></label>
<label className="grid gap-1 text-sm font-medium">Title<Input required maxLength={1000} value={draft.subject} onChange={e => update("subject", e.target.value)} /></label>
{(["detail", "scope", "rationale", "question", "sql"] as const).filter(k => k !== "sql" || draft.family === "solved_question").map(key => <label key={key} className="grid gap-1 text-sm font-medium">{fieldLabels[key]}
<textarea aria-label={fieldLabels[key]} className={control} rows={key === "sql" ? 6 : 3} value={draft[key]}
}}>{Object.entries(families).map(([k, v]) => <option key={k} value={k}>{t(v)}</option>)}</select></label>
<label className="grid gap-1 text-sm font-medium">{t("Title")}<Input required maxLength={1000} value={draft.subject} onChange={e => update("subject", e.target.value)} /></label>
{(["detail", "scope", "rationale", "question", "sql"] as const).filter(k => k !== "sql" || draft.family === "solved_question").map(key => <label key={key} className="grid gap-1 text-sm font-medium">{t(fieldLabels[key])}
<textarea aria-label={t(fieldLabels[key])} className={control} rows={key === "sql" ? 6 : 3} value={draft[key]}
required={key === "scope" || (draft.family === "solved_question" && (key === "sql" || key === "question")) || (draft.family === "explained_error" && (key === "detail" || key === "rationale"))}
onChange={e => update(key, e.target.value)} /></label>)}
<label className="grid gap-1 text-sm font-medium">Concepts (comma separated)<Input value={draft.concepts.join(",")} onChange={e => update("concepts", e.target.value.split(","))} /></label>
<fieldset className="space-y-3"><legend className="mb-2 text-sm font-semibold">Schema dependencies</legend>
<label className="grid gap-1 text-sm font-medium">{t("Concepts (comma separated)")}<Input value={draft.concepts.join(",")} onChange={e => update("concepts", e.target.value.split(","))} /></label>
<fieldset className="space-y-3"><legend className="mb-2 text-sm font-semibold">{t("Schema dependencies")}</legend>
{draft.dependencies.map((dep, i) => <div key={i} className="grid grid-cols-2 gap-2 border-b pb-3">
{(["database", "schema_name", "table", "column"] as const).map(key => <label key={key} className="grid gap-1 text-xs">{key === "schema_name" ? "Schema" : key[0].toUpperCase()+key.slice(1)}
<Input required={key === "database"} aria-label={`Dependency ${i + 1} ${key}`} value={dep[key]} onChange={e => update("dependencies", draft.dependencies.map((d, n) => n === i ? { ...d, [key]: e.target.value } : d))} /></label>)}
<Button type="button" variant="ghost" onClick={() => update("dependencies", draft.dependencies.filter((_, n) => n !== i))}>Remove dependency {i + 1}</Button></div>)}
<Button type="button" variant="outline" onClick={() => update("dependencies", [...draft.dependencies, { database: "", schema_name: "", table: "", column: "" }])}>Add dependency</Button>
{(["database", "schema_name", "table", "column"] as const).map(key => <label key={key} className="grid gap-1 text-xs">{key === "schema_name" ? t("Schema") : t(key[0].toUpperCase()+key.slice(1))}
<Input required={key === "database"} aria-label={t("Dependency {number} {key}", { number: i + 1, key: t(key) })} value={dep[key]} onChange={e => update("dependencies", draft.dependencies.map((d, n) => n === i ? { ...d, [key]: e.target.value } : d))} /></label>)}
<Button type="button" variant="ghost" onClick={() => update("dependencies", draft.dependencies.filter((_, n) => n !== i))}>{t("Remove dependency")} {i + 1}</Button></div>)}
<Button type="button" variant="outline" onClick={() => update("dependencies", [...draft.dependencies, { database: "", schema_name: "", table: "", column: "" }])}>{t("Add dependency")}</Button>
</fieldset>
<fieldset className="space-y-3"><legend className="mb-2 text-sm font-semibold">Linked cards</legend>
<fieldset className="space-y-3"><legend className="mb-2 text-sm font-semibold">{t("Linked cards")}</legend>
{draft.links.map((link, i) => <div key={link.target_id} className="space-y-2 border-b pb-3"><p className="break-all font-mono text-xs">{link.target_id}</p>
<label className="grid gap-1 text-sm">Link meaning<Input required value={link.meaning} onChange={e => update("links", draft.links.map((l, n) => n === i ? { ...l, meaning: e.target.value } : l))} /></label>
<Button type="button" variant="ghost" onClick={() => update("links", draft.links.filter((_, n) => n !== i))}>Remove link {i + 1}</Button></div>)}
<label className="grid gap-1 text-sm">Search cards to link<Input value={linkSearch} onChange={e => setLinkSearch(e.target.value)} /></label>
{linkOptions.isError && <p role="alert">{apiErrorMessage(linkOptions.error)}</p>}
<ul>{linkOptions.data?.items.filter(c => c.id !== selected?.id && !draft.links.some(l => l.target_id === c.id)).map(c => <li key={c.id}><Button type="button" variant="ghost" onClick={() => { update("links", [...draft.links, { target_id: c.id, meaning: "" }]); setLinkSearch(""); }}>Link {c.subject}</Button></li>)}</ul>
<label className="grid gap-1 text-sm">{t("Link meaning")}<Input required value={link.meaning} onChange={e => update("links", draft.links.map((l, n) => n === i ? { ...l, meaning: e.target.value } : l))} /></label>
<Button type="button" variant="ghost" onClick={() => update("links", draft.links.filter((_, n) => n !== i))}>{t("Remove link")} {i + 1}</Button></div>)}
<label className="grid gap-1 text-sm">{t("Search cards to link")}<Input value={linkSearch} onChange={e => setLinkSearch(e.target.value)} /></label>
{linkOptions.isError && <p role="alert">{t(apiErrorMessage(linkOptions.error))}</p>}
<ul>{linkOptions.data?.items.filter(c => c.id !== selected?.id && !draft.links.some(l => l.target_id === c.id)).map(c => <li key={c.id}><Button type="button" variant="ghost" onClick={() => { update("links", [...draft.links, { target_id: c.id, meaning: "" }]); setLinkSearch(""); }}>{t("Link")} {c.subject}</Button></li>)}</ul>
</fieldset>
</fieldset>
<footer className="flex gap-2 border-t pt-4"><Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save card"}</Button>
<Button type="button" variant="outline" disabled={busy} onClick={() => setDraft(null)}>Cancel</Button></footer>
<footer className="flex gap-2 border-t pt-4"><Button type="submit" disabled={busy}>{busy ? t("Saving…") : t("Save card")}</Button>
<Button type="button" variant="outline" disabled={busy} onClick={() => setDraft(null)}>{t("Cancel")}</Button></footer>
</form>}
</section>
</div>
<section aria-label="Pending index updates" className="shrink-0 border-t bg-muted/30 px-6 py-4">
<h2 className="text-sm font-semibold">Pending index updates</h2>
{pending.isError && <p role="alert" className="mt-2 text-sm">{apiErrorMessage(pending.error)}</p>}
{pending.data?.length === 0 && <p className="mt-1 text-xs text-muted-foreground">All saved changes have been indexed.</p>}
<section aria-label={t("Pending index updates")} className="shrink-0 border-t bg-muted/30 px-6 py-4">
<h2 className="text-sm font-semibold">{t("Pending index updates")}</h2>
{pending.isError && <p role="alert" className="mt-2 text-sm">{t(apiErrorMessage(pending.error))}</p>}
{pending.data?.length === 0 && <p className="mt-1 text-xs text-muted-foreground">{t("All saved changes have been indexed.")}</p>}
<ul>{pending.data?.map(p => <li key={p.card_id} className="flex flex-wrap items-center justify-between gap-3 border-b py-3 text-sm">
<div><span>{p.action === "delete" ? "Deleted card: index cleanup pending" : "Saved card: index update pending"}</span><p className="mt-1 break-all font-mono text-xs text-muted-foreground">{p.card_id}</p></div>
<Button variant="outline" disabled={busy || dirty} onClick={() => void mutate(() => retryMemory(workspace, p.card_id))}>Retry index update</Button>
<div><span>{p.action === "delete" ? t("Deleted card: index cleanup pending") : t("Saved card: index update pending")}</span><p className="mt-1 break-all font-mono text-xs text-muted-foreground">{p.card_id}</p></div>
<Button variant="outline" disabled={busy || dirty} onClick={() => void mutate(() => retryMemory(workspace, p.card_id))}>{t("Retry index update")}</Button>
</li>)}</ul>
</section>
</>;
@@ -6,8 +6,23 @@ import { useSessionStore } from "../store/sessionStore";
import { isNearBottom } from "./activityScroll";
import * as activityPanelModule from "./ModelActivityPanel";
import { ModelActivityPanel } from "./ModelActivityPanel";
import { setLocale } from "../i18n";
beforeEach(() => useSessionStore.getState().resetSession());
afterEach(() => act(() => setLocale("en")));
test("changes transcript chrome without translating model text that matches a message ID", () => {
useSessionStore.setState({ activityLog: [
{ kind: "assistant", phase: "F1", text: "Choose a workspace" },
] });
render(<ModelActivityPanel onClose={vi.fn()} />);
act(() => setLocale("it"));
expect(screen.getByText("Attività del modello")).toBeVisible();
expect(screen.getByText("Risposta")).toBeVisible();
expect(screen.getByLabelText("Fase F1")).toBeVisible();
expect(screen.getByText("Choose a workspace")).toBeVisible();
expect(screen.queryByText("Scegli un workspace")).not.toBeInTheDocument();
});
test("renders question, reasoning, and response in chronological order", () => {
const store = useSessionStore.getState();
+11 -9
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useLayoutEffect, useRef } from "react";
import { Settings2, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
@@ -46,11 +47,12 @@ function MarkdownBody({ entry }: { entry: ActivityEntry }) {
}
function ActivityRow({ entry }: { entry: ActivityEntry }) {
const { t: translate } = useI18n();
return (
<article className="w-full min-w-0 border-b border-border/50 py-2 last:border-b-0">
<div className="mb-1 flex flex-wrap items-center gap-1 text-[0.65rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
<span className="text-foreground/80">{ACTIVITY_LABELS[entry.kind]}</span>
{entry.phase && <span aria-label={`Phase ${entry.phase}`}>{entry.phase}</span>}
<span className="text-foreground/80">{translate(ACTIVITY_LABELS[entry.kind] ?? entry.kind)}</span>
{entry.phase && <span aria-label={translate("Phase {phase}", { phase: entry.phase })}>{entry.phase}</span>}
</div>
<MarkdownBody entry={entry} />
</article>
@@ -69,6 +71,7 @@ export function ModelActivityPanel({
onOpenWorkspaceManager?: () => void;
hidden?: boolean;
}) {
const { t: translate } = useI18n();
const activityLog = useSessionStore((s) => s.activityLog);
const visibleActivity = activityLog.filter(isVisibleModelActivity);
const visibleCount = visibleActivity.length;
@@ -87,12 +90,11 @@ export function ModelActivityPanel({
: "fixed inset-y-0 left-0 z-30 flex min-w-0 w-[min(90vw,24rem)] shrink-0 flex-col border-r border-border bg-sidebar"
}>
<div className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
<h2 className="font-heading text-sm font-semibold text-foreground">Model activity</h2>
<h2 className="font-heading text-sm font-semibold text-foreground">{translate("Model activity")}</h2>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" aria-label="Workspace management" onClick={onOpenWorkspaceManager}>
<Settings2 className="size-3.5" />Workspace management
</Button>
<Button size="icon-sm" variant="ghost" aria-label="Close model activity" onClick={onClose}>
<Button size="sm" variant="ghost" aria-label={translate("Workspace management")} onClick={onOpenWorkspaceManager}>
<Settings2 className="size-3.5" />{translate("Workspace management")}</Button>
<Button size="icon-sm" variant="ghost" aria-label={translate("Close model activity")} onClick={onClose}>
<X className="size-4" />
</Button>
</div>
@@ -101,14 +103,14 @@ export function ModelActivityPanel({
ref={scrollRef}
data-testid="activity-scroll"
tabIndex={0}
aria-label="Model activity timeline"
aria-label={translate("Model activity timeline")}
className="w-full min-w-0 flex-1 overflow-y-auto px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40"
onScroll={(event) => {
followRef.current = isNearBottom(event.currentTarget);
}}
>
{visibleActivity.length === 0 ? (
<p className="py-3 text-sm text-muted-foreground">No activity yet.</p>
<p className="py-3 text-sm text-muted-foreground">{translate("No activity yet.")}</p>
) : (
visibleActivity.map((entry, index) => (
<ActivityRow key={`${index}-${entry.toolCallId ?? entry.kind}`} entry={entry} />
+16 -1
View File
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import { setLocale } from "../i18n";
import userEvent from "@testing-library/user-event";
import { NavSessions } from "./NavSessions";
import type { SessionSummary } from "../api/types";
@@ -8,6 +9,20 @@ const SESSIONS: SessionSummary[] = [
{ id: "s2", status: "closed", question: "Seconda domanda", summary: "r", created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: "Etichetta", group: null, archived: false },
];
afterEach(() => act(() => setLocale("en")));
test("translates session status and selection names while preserving authored names and IDs", async () => {
const onOpenPanel = vi.fn();
render(<NavSessions sessions={SESSIONS} activeSessionId="s2" showOwner onOpenPanel={onOpenPanel} menuFor={() => null} />);
act(() => setLocale("it"));
expect(screen.getByRole("checkbox", { name: "Seleziona Etichetta" })).toBeVisible();
expect(screen.getByTitle("Interrotta dall'utente")).toBeInTheDocument();
expect(screen.getByText("Come va?")).toBeVisible();
expect(screen.getAllByText("Autore: Sconosciuto")).toHaveLength(2);
await userEvent.click(screen.getByText("Etichetta"));
expect(onOpenPanel).toHaveBeenCalledWith("s2");
});
test("lists sessions and shows name when present", () => {
render(<NavSessions sessions={SESSIONS} activeSessionId={null} onOpenPanel={vi.fn()} menuFor={() => null} />);
expect(screen.getByText("Come va?")).toBeInTheDocument();
+7 -5
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
// frontend/src/shell/NavSessions.tsx
import type { ReactNode } from "react";
import type { SessionSummary } from "../api/types";
@@ -24,10 +25,11 @@ export function NavSessions({
sessions, activeSessionId, onOpenPanel, menuFor,
selectedIds = new Set<string>(), onSelectionChange = () => undefined, showOwner = false,
}: Props) {
const { t: translate } = useI18n();
if (sessions.length === 0) {
return (
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
No sessions yet. Start with <span className="font-medium text-foreground">New session</span>.
{translate("No sessions yet.")} <span className="font-medium text-foreground">{translate("Start with New session.")}</span>
</p>
);
}
@@ -46,7 +48,7 @@ export function NavSessions({
role="button"
tabIndex={0}
aria-label={label}
title={`${label} · ${s.status}`}
title={`${label} · ${translate(indicator.label)}`}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
@@ -62,13 +64,13 @@ export function NavSessions({
>
<Checkbox
checked={selectedIds.has(s.id)}
aria-label={`Select ${label}`}
aria-label={translate("Select {name}", { name: label })}
onCheckedChange={(selected) => onSelectionChange(s.id, selected === true)}
onClick={(event) => event.stopPropagation()}
/>
<span
aria-hidden
title={indicator.label}
title={translate(indicator.label)}
className={["size-1.5 shrink-0 rounded-full", indicator.color].join(" ")}
/>
<span className="min-w-0 flex-1">
@@ -82,7 +84,7 @@ export function NavSessions({
</span>
{showOwner && (
<span className="block truncate text-[0.65rem] text-muted-foreground">
Owner: {s.author ?? "Unknown"}
{translate("Owner: {name}", { name: s.author ?? translate("Unknown") })}
</span>
)}
</span>
+16 -1
View File
@@ -1,4 +1,5 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import { setLocale } from "../i18n";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
@@ -13,6 +14,20 @@ const readyStatus = {
checkedAt: "2026-08-05T10:00:00.000Z",
};
test("updates Pi controls and host instructions when the locale changes without remounting", async () => {
renderManagement(true);
await screen.findByRole("button", { name: "Test catalog default" });
try {
act(() => setLocale("it"));
expect(screen.getByRole("button", { name: "Verifica il modello predefinito del catalogo" })).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Manutenzione host" }));
expect(screen.getByRole("heading", { name: "Apri la directory del progetto" })).toBeVisible();
act(() => setLocale("en"));
expect(screen.getByRole("heading", { name: "Open the project root" })).toBeVisible();
expect(screen.getByRole("button", { name: "Runtime and models" })).toBeVisible();
} finally { act(() => setLocale("en")); }
});
function renderManagement(page = false) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
+100 -92
View File
@@ -1,3 +1,5 @@
import { useI18n } from "../i18n";
import { adminMessage, renderAdminMessage, type AdminMessage } from "./database-management/messages";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, CircleAlert, FlaskConical, LoaderCircle, ScrollText } from "lucide-react";
@@ -12,7 +14,7 @@ import { Button } from "../components/ui/button";
import { nextTabIndex } from "../components/ui/tab-navigation";
import { WorkAreaPanel } from "./WorkAreaPanel";
type Feedback = { tone: "success" | "error"; message: string } | undefined;
type Feedback = { tone: "success" | "error"; message: AdminMessage } | undefined;
type SmokeState = "passed" | "failed" | undefined;
function errorMessage(error: unknown, fallback: string): string {
@@ -25,21 +27,22 @@ function ReadinessRail({ ready, configured, credentials, smokeState }: {
credentials: "present" | "missing";
smokeState: SmokeState;
}) {
const { t } = useI18n();
return (
<section
data-testid="pi-readiness-rail"
aria-label="Pi readiness sequence"
aria-label={t("Pi readiness sequence")}
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"} />
<RailItem label={t("Runtime")} value={ready ? "Runtime ready" : "Runtime needs attention"} state={ready ? "ready" : "attention"} />
<RailItem label={t("Defaults")} value={configured ? "Defaults ready" : "Defaults incomplete"} state={configured ? "ready" : "attention"} />
<RailItem
label="Credentials"
label={t("Credentials")}
value={`Credentials ${credentials}`}
state={credentials === "present" ? "ready" : "attention"}
/>
<RailItem
label="Configuration test"
label={t("Configuration test")}
value={smokeState === "passed" ? "Saved configuration test passed" : smokeState === "failed" ? "Saved configuration test failed" : "Saved configuration test not run"}
state={smokeState === "passed" ? "ready" : smokeState === "failed" ? "attention" : "idle"}
/>
@@ -48,12 +51,13 @@ function ReadinessRail({ ready, configured, credentials, smokeState }: {
}
function RailItem({ label, value, state }: { label: string; value: string; state: "ready" | "attention" | "idle" }) {
const { t } = useI18n();
const iconClass = state === "ready" ? "text-[oklch(var(--success))]" : state === "attention" ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground";
return <div className="min-w-0">
<p className="thot-label">{label}</p>
<p className="thot-label">{t(label)}</p>
<p className={`mt-1 flex items-start gap-1.5 text-sm font-semibold ${iconClass}`}>
{state === "ready" ? <CheckCircle2 className="mt-0.5 size-3.5 shrink-0" /> : state === "attention" ? <CircleAlert className="mt-0.5 size-3.5 shrink-0" /> : <span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-current" />}
<span>{value}</span>
<span>{t(value)}</span>
</p>
</div>;
}
@@ -129,81 +133,84 @@ const piPlatforms: Array<{ id: PiPlatform; label: string; details: PiPlatformDet
];
function PiCodeBlock({ children, className = "" }: { children: string; className?: string }) {
const { t } = useI18n();
return <pre className={`overflow-x-auto rounded bg-muted p-2 ${className}`}><code>{children}</code></pre>;
}
function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
const { t } = useI18n();
return <ol className="grid gap-4 pl-5 marker:font-semibold marker:text-muted-foreground">
<li>
<h4 className="font-semibold text-foreground">Open the project root</h4>
<p className="mt-1 text-muted-foreground">Using {details.terminal}, before editing files or running any lifecycle command, enter the exact checkout or worktree root. Run the platform command below, then run every command in this list from that directory: <code>tht</code> resolves the installation descriptor and configuration from the selected project. A worktree is an independent checkout, so this prevents changing a different copy by mistake.</p>
<h4 className="font-semibold text-foreground">{t("Open the project root")}</h4>
<p className="mt-1 text-muted-foreground">{t("Using")} {t(details.terminal)}{t(", before editing files or running any lifecycle command, enter the exact checkout or worktree root. Run the platform command below, then run every command in this list from that directory:")} <code>tht</code> {t("resolves the installation descriptor and configuration from the selected project. A worktree is an independent checkout, so this prevents changing a different copy by mistake.")}</p>
<PiCodeBlock className="mt-2">{details.changeDirectoryCommand}</PiCodeBlock>
<p className="mt-2 text-muted-foreground">The relevant layout is:</p>
<p className="mt-2 text-muted-foreground">{t("The relevant layout is:")}</p>
<PiCodeBlock className="mt-1">project-root/
├── compose.yaml
├── deploy/
│ └── &lt;installation-id&gt;/
│ └── thothii-installation.yaml
└── docker/</PiCodeBlock>
<p className="mt-2 text-muted-foreground">The <code>deploy/</code> directory contains the selected installation descriptor and profile files. You do not need to create or manage a <code>bin/</code> directory: <code>tht</code> is the installed host CLI. If discovery finds multiple descriptors, pass the intended one explicitly with <code>--installation &lt;absolute-path&gt;/thothii-installation.yaml</code>. If <code>tht</code> is not on your <code>PATH</code>, install it using the installation guide.</p>
<p className="mt-2 text-muted-foreground">{t("The")} <code>deploy/</code> {t("directory contains the selected installation descriptor and profile files. You do not need to create or manage a")} <code>bin/</code> {t("directory:")} <code>tht</code> {t("is the installed host CLI. If discovery finds multiple descriptors, pass the intended one explicitly with")} <code>--installation &lt;absolute-path&gt;/thothii-installation.yaml</code>{t(". If")} <code>tht</code> {t("is not on your")} <code>PATH</code>{t(", install it using the installation guide.")}</p>
</li>
<li>
<h4 className="font-semibold text-foreground">Edit the Installation Model Catalog</h4>
<p className="mt-1 text-muted-foreground">Edit <code>{details.catalogPath}</code> when adding or correcting a provider or model. Its <code>modelCatalog</code> block is the only authored model source for sessions, metadata generation, and embedding. It declares endpoint references and authentication modes, but never secret values. Provider integrations remain declarative; do not add model-provider code under <code>harness/.pi/extensions/</code> or edit files under <code>generated/</code>.</p>
<h4 className="font-semibold text-foreground">{t("Edit the Installation Model Catalog")}</h4>
<p className="mt-1 text-muted-foreground">{t("Edit")} <code>{details.catalogPath}</code> {t("when adding or correcting a provider or model. Its")} <code>modelCatalog</code> {t("block is the only authored model source for sessions, metadata generation, and embedding. It declares endpoint references and authentication modes, but never secret values. Provider integrations remain declarative; do not add model-provider code under")} <code>harness/.pi/extensions/</code> {t("or edit files under")} <code>generated/</code>.</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">providers </dt><dd>maps stable provider keys to endpoint, authentication, and runtime adapters.</dd>
<dt className="font-mono text-foreground">models </dt><dd>maps upstream model keys; the canonical identity is provider/model.</dd>
<dt className="font-mono text-foreground">session </dt><dd>makes a model selectable for interactive work.</dd>
<dt className="font-mono text-foreground">metadataGeneration </dt><dd>makes a model available to metadata generation.</dd>
<dt className="font-mono text-foreground">embedding </dt><dd>declares the one installation embedding identity and its dimensions.</dd>
<dt className="font-mono text-foreground">providers </dt><dd>{t("maps stable provider keys to endpoint, authentication, and runtime adapters.")}</dd>
<dt className="font-mono text-foreground">models </dt><dd>{t("maps upstream model keys; the canonical identity is provider/model.")}</dd>
<dt className="font-mono text-foreground">session </dt><dd>{t("makes a model selectable for interactive work.")}</dd>
<dt className="font-mono text-foreground">metadataGeneration </dt><dd>{t("makes a model available to metadata generation.")}</dd>
<dt className="font-mono text-foreground">embedding </dt><dd>{t("declares the one installation embedding identity and its dimensions.")}</dd>
</dl>
</li>
<li>
<h4 className="font-semibold text-foreground">Choose eligibility and defaults</h4>
<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>
<h4 className="font-semibold text-foreground">{t("Choose eligibility and defaults")}</h4>
<p className="mt-1 text-muted-foreground">{t("Use")} <code>modelCatalog.defaults.interaction</code> {t("as the single installation-wide LLM default for Core and Administration. When Admin AI is configured, a selectable model needs both")} <code>session</code> {t("and")} <code>metadataGeneration</code> {t("blocks. The user's last model choice takes precedence and does not depend on workspace. Runtime files such as Pi")} <code>models.json</code> {t("and")} <code>settings.json</code> {t("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.interaction </dt><dd>contains exactly one canonical provider/model identity for every workspace.</dd>
<dt className="font-mono text-foreground">defaults.interaction </dt><dd>{t("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>
<p className="mt-2 text-muted-foreground">{t("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>
<p className="mt-1 text-muted-foreground">During the initial installation, run <code>tht setup</code> and complete the prompt named “Pi credentials file location”; it can create the empty protected template. On an existing installation with a missing credential, correct the protected credential file selected during setup by following the installation guide. Keep {details.credentialProtection}, then restart and run <code>tht pi doctor</code> and <code>tht pi test</code>. Never paste credentials into the installation YAML, this page, a command, or a log: YAML may contain only approved environment-variable names or authentication modes.</p>
<h4 className="font-semibold text-foreground">{t("Complete the provider setup")}</h4>
<p className="mt-1 text-muted-foreground">{t("During the initial installation, run")} <code>tht setup</code> {t("and complete the prompt named “Pi credentials file location”; it can create the empty protected template. On an existing installation with a missing credential, correct the protected credential file selected during setup by following the installation guide. Keep")} {t(details.credentialProtection)}{t(", then restart and run")} <code>tht pi doctor</code> {t("and")} <code>tht pi test</code>{t(". Never paste credentials into the installation YAML, this page, a command, or a log: YAML may contain only approved environment-variable names or authentication modes.")}</p>
</li>
<li>
<h4 className="font-semibold text-foreground">Reload Pi configuration</h4>
<p className="mt-1 text-muted-foreground">After changing the Installation Model Catalog or a provider credential, reload the running <code>core</code> service. The command validates the YAML and regenerates every runtime projection before recreation. This is a configuration reload, not a Pi version update; it keeps the current image.</p>
<h4 className="font-semibold text-foreground">{t("Reload Pi configuration")}</h4>
<p className="mt-1 text-muted-foreground">{t("After changing the Installation Model Catalog or a provider credential, reload the running")} <code>core</code> {t("service. The command validates the YAML and regenerates every runtime projection before recreation. This is a configuration reload, not a Pi version update; it keeps the current image.")}</p>
<PiCodeBlock className="mt-2">{details.restartCommand}</PiCodeBlock>
</li>
<li>
<h4 className="font-semibold text-foreground">Update the Pi version</h4>
<p className="mt-1 text-muted-foreground">Use <code>tht pi update</code> for the routine build: it resolves the latest stable Pi release from the registry, builds it locally, drains active sessions, and recreates only <code>core</code>. Add <code>--version &lt;VERSION&gt;</code> when an explicitly reviewed version is required. The digest-pinned <code>--source pull</code> form is for an already-built, reviewed registry image; its declared version must match the digest-pinned image. Configuration changes use Reload above, not update.</p>
<h4 className="font-semibold text-foreground">{t("Update the Pi version")}</h4>
<p className="mt-1 text-muted-foreground">{t("Use")} <code>tht pi update</code> {t("for the routine build: it resolves the latest stable Pi release from the registry, builds it locally, drains active sessions, and recreates only")} <code>core</code>{t(". Add")} <code>--version &lt;VERSION&gt;</code> {t("when an explicitly reviewed version is required. The digest-pinned")} <code>--source pull</code> {t("form is for an already-built, reviewed registry image; its declared version must match the digest-pinned image. Configuration changes use Reload above, not update.")}</p>
<PiCodeBlock className="mt-2">{details.updateCommand}</PiCodeBlock>
<p className="mt-2 text-[11px] text-muted-foreground">Advanced: pull an immutable, digest-pinned image.</p>
<p className="mt-2 text-[11px] text-muted-foreground">{t("Advanced: pull an immutable, digest-pinned image.")}</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.pullCommand}</PiCodeBlock>
</li>
<li>
<h4 className="font-semibold text-foreground">Diagnose and recover a failed operation</h4>
<p className="mt-1 text-muted-foreground">Start with these diagnostics, in order:</p>
<h4 className="font-semibold text-foreground">{t("Diagnose and recover a failed operation")}</h4>
<p className="mt-1 text-muted-foreground">{t("Start with these diagnostics, in order:")}</p>
<PiCodeBlock className="mt-2 text-[11px] text-muted-foreground">{details.diagnosticCommands}</PiCodeBlock>
<dl className="mt-2 grid gap-2 text-muted-foreground">
<dt className="font-semibold text-foreground">Configuration</dt><dd>Invalid YAML, an unknown model default, or an incompatible use/adapter combination: fix the reported <code>modelCatalog</code> field, then reload. Do not repair generated JSON directly.</dd>
<dt className="font-semibold text-foreground">Credentials</dt><dd>Missing or unreadable credential: correct the protected credential file selected during setup and its permissions, without printing the file.</dd>
<dt className="font-semibold text-foreground">Provider connectivity</dt><dd>Wrong <code>baseUrl</code>, network, or provider error: correct the catalog endpoint or network, then retry.</dd>
<dt className="font-semibold text-foreground">Docker and disk</dt><dd>Unhealthy Docker or insufficient disk: restore Docker health or free space before retrying.</dd>
<dt className="font-semibold text-foreground">{t("Configuration")}</dt><dd>{t("Invalid YAML, an unknown model default, or an incompatible use/adapter combination: fix the reported")} <code>modelCatalog</code> {t("field, then reload. Do not repair generated JSON directly.")}</dd>
<dt className="font-semibold text-foreground">{t("Credentials")}</dt><dd>{t("Missing or unreadable credential: correct the protected credential file selected during setup and its permissions, without printing the file.")}</dd>
<dt className="font-semibold text-foreground">{t("Provider connectivity")}</dt><dd>{t("Wrong")} <code>baseUrl</code>{t(", network, or provider error: correct the catalog endpoint or network, then retry.")}</dd>
<dt className="font-semibold text-foreground">{t("Docker and disk")}</dt><dd>{t("Unhealthy Docker or insufficient disk: restore Docker health or free space before retrying.")}</dd>
</dl>
<p className="mt-2 text-muted-foreground">If maintenance is inactive, no recovery is needed: correct the cause and retry the original command.</p>
<p className="mt-2 text-muted-foreground">If maintenance is active after an update, rollback the previous image:</p>
<p className="mt-2 text-muted-foreground">{t("If maintenance is inactive, no recovery is needed: correct the cause and retry the original command.")}</p>
<p className="mt-2 text-muted-foreground">{t("If maintenance is active after an update, rollback the previous image:")}</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.rollbackCommand}</PiCodeBlock>
<p className="mt-2 text-muted-foreground">If maintenance is active after a restart, fix the cause first, then recover the captured lifecycle state:</p>
<p className="mt-2 text-muted-foreground">{t("If maintenance is active after a restart, fix the cause first, then recover the captured lifecycle state:")}</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.recoverCommand}</PiCodeBlock>
<p className="mt-2 text-muted-foreground">After the selected remedy, verify the installation:</p>
<p className="mt-2 text-muted-foreground">{t("After the selected remedy, verify the installation:")}</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.verificationCommands}</PiCodeBlock>
</li>
</ol>;
}
function PiPlatformInstructions({ hostPlatform }: { hostPlatform?: PiPlatform }) {
const { t } = useI18n();
const [chosenPlatform, setActivePlatform] = useState<PiPlatform>();
const activePlatform = chosenPlatform ?? hostPlatform ?? "linux";
const tabRefs = useRef<Record<PiPlatform, HTMLButtonElement | null>>({ linux: null, macos: null, windows: null });
@@ -222,9 +229,9 @@ function PiPlatformInstructions({ hostPlatform }: { hostPlatform?: PiPlatform })
}
return (
<section aria-label="Pi operator workflow" className="mt-2 grid gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
<p className="text-muted-foreground">Using the host terminal:</p>
<div role="tablist" aria-label="Pi host platform" className="grid grid-cols-3 gap-1 rounded-md bg-muted p-1">
<section aria-label={t("Pi operator workflow")} className="mt-2 grid gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
<p className="text-muted-foreground">{t("Using the host terminal:")}</p>
<div role="tablist" aria-label={t("Pi host platform")} className="grid grid-cols-3 gap-1 rounded-md bg-muted p-1">
{piPlatforms.map((platform) => (
<button
key={platform.id}
@@ -266,6 +273,7 @@ function PiPlatformInstructions({ hostPlatform }: { hostPlatform?: PiPlatform })
}
export function PiManagement({ open, onClose, page = false }: { open: boolean; onClose: () => void; page?: boolean }) {
const { t } = useI18n();
const [section, setSection] = useState<"runtime" | "instructions">("runtime");
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<Feedback>();
@@ -297,7 +305,7 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
tone: result.ready ? "success" : "error",
message: result.ready
? "Catalog default test passed."
: `Catalog default test failed.${result.message ? ` ${result.message}` : ""}`,
: adminMessage("Catalog default test failed.{message}", { message: result.message ? ` ${result.message}` : "" }),
});
},
onError: (error) => {
@@ -322,20 +330,20 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
<WorkAreaPanel
open={open}
mode={page ? "page" : "panel"}
ariaLabel="Pi management"
ariaLabel={t("Pi management")}
width="expanded"
eyebrow="Installation controls"
title="Pi management"
description="Review the bundled runtime, inspect the catalog default, and test its provider configuration."
eyebrow={t("Installation controls")}
title={t("Pi management")}
description={t("Review the bundled runtime, inspect the catalog default, and test its provider configuration.")}
onClose={onClose}
closeLabel="Close Pi management"
closeLabel={t("Close Pi management")}
bodyClassName={page ? "thot-work-area-page__body--pi" : undefined}
>
<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>
{page && <nav aria-label={t("Pi sections")} className="thot-pi-index">
<p className="thot-administration-eyebrow mb-3">{t("Installation")}</p>
<Button variant={section === "runtime" ? "navigationActive" : "ghost"} className="w-full justify-start mb-2" aria-current={section === "runtime" ? "page" : undefined} onClick={() => setSection("runtime")}>{t("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")}>{t("Host maintenance")}</Button>
</nav>}
<div className={page ? "thot-pi-detail" : undefined}>
<div hidden={page && section !== "instructions"}>
@@ -343,24 +351,24 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
</div>
<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>
<p className="mt-1 text-muted-foreground">Ask an installation administrator to manage the model catalog and diagnostics.</p>
<div role="alert" aria-label={t("Pi management unavailable")} className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
<p className="font-semibold">{t("Pi management is not permitted")}</p>
<p className="mt-1 text-muted-foreground">{t("Ask an installation administrator to manage the model catalog and diagnostics.")}</p>
</div>
) : unavailable ? (
<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">{errorMessage(statusQuery.error, "Pi management is unavailable")}</p>
<Button className="mt-3" size="sm" variant="outline" onClick={() => { void statusQuery.refetch(); }}>Retry</Button>
<div role="alert" aria-label={t("Pi management unavailable")} className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
<p className="font-semibold">{t(errorMessage(statusQuery.error, t("Pi management is unavailable")))}</p>
<Button className="mt-3" size="sm" variant="outline" onClick={() => { void statusQuery.refetch(); }}>{t("Retry")}</Button>
</div>
) : loading ? <p className="text-sm text-muted-foreground">Loading Pi management…</p> : statusQuery.data && (
) : loading ? <p className="text-sm text-muted-foreground">{t("Loading Pi management…")}</p> : statusQuery.data && (
<div className="grid gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="thot-label">Bundled runtime</p>
<p className="mt-1 font-mono text-sm font-semibold">Pi {statusQuery.data.version ?? "version unavailable"}</p>
<p className="thot-label">{t("Bundled runtime")}</p>
<p className="mt-1 font-mono text-sm font-semibold">Pi {statusQuery.data.version ?? t("version unavailable")}</p>
</div>
<p role="status" aria-label="Pi readiness" className={`rounded-md border px-2.5 py-1 text-xs font-semibold ${statusQuery.data.ready ? "border-[oklch(var(--success)/0.4)] bg-[oklch(var(--success)/0.1)] text-foreground" : "border-amber-500/35 bg-amber-500/10 text-amber-800 dark:text-amber-300"}`}>
{statusQuery.data.ready ? "Ready" : "Needs attention"}
<p role="status" aria-label={t("Pi readiness")} className={`rounded-md border px-2.5 py-1 text-xs font-semibold ${statusQuery.data.ready ? "border-[oklch(var(--success)/0.4)] bg-[oklch(var(--success)/0.1)] text-foreground" : "border-amber-500/35 bg-amber-500/10 text-amber-800 dark:text-amber-300"}`}>
{statusQuery.data.ready ? t("Ready") : t("Needs attention")}
</p>
</div>
@@ -371,57 +379,57 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
smokeState={smokeState}
/>
{statusQuery.data.message && <p role="status" aria-live="polite" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">{statusQuery.data.message}</p>}
{feedback && <p role="status" aria-label="Pi management feedback" aria-live="polite" className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${feedback.tone === "error" ? "border-destructive/30 bg-destructive/5" : "border-border bg-muted/50"}`}>
{statusQuery.data.message && <p role="status" aria-live="polite" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">{t(statusQuery.data.message)}</p>}
{feedback && <p role="status" aria-label={t("Pi management feedback")} aria-live="polite" className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${feedback.tone === "error" ? "border-destructive/30 bg-destructive/5" : "border-border bg-muted/50"}`}>
{feedback.tone === "error" ? <CircleAlert className="mt-0.5 size-4 shrink-0 text-destructive" /> : <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-[oklch(var(--success))]" />}
{feedback.message}
{renderAdminMessage(t, feedback.message)}
</p>}
<section aria-label="Installation catalog default" className="border-t border-border/70 pt-5">
<section aria-label={t("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.interaction</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">{t("Installation catalog default")}</h3><p className="mt-1 text-sm text-muted-foreground">{t("This value is read from")} <code>modelCatalog.defaults.interaction</code>{t(". Change the installation YAML and reload Pi; this page never creates a second model default.")}</p></div>
</div>
{catalogReady ? (
<>
<div className="mt-4 grid gap-3 text-sm sm:grid-cols-3">
<div><p className="thot-label">Provider</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.provider}</p></div>
<div><p className="thot-label">Model</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.model}</p></div>
<div><p className="thot-label">Reasoning</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.reasoning}</p></div>
<div><p className="thot-label">{t("Provider")}</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.provider}</p></div>
<div><p className="thot-label">{t("Model")}</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.model}</p></div>
<div><p className="thot-label">{t("Reasoning")}</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.reasoning}</p></div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={smokeMutation.isPending} onClick={testCatalogDefault}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}Test catalog default</Button>
<Button size="sm" variant="outline" disabled={smokeMutation.isPending} onClick={testCatalogDefault}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}{t("Test catalog default")}</Button>
</div>
</>
) : (
<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.interaction</code> in the installation YAML, reload Pi, then retry this panel.</p>
<div role="alert" aria-label={t("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">{t("The catalog default is unavailable.")}</p>
<p className="mt-1">{t("Fix")} <code>modelCatalog.defaults.interaction</code> {t("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>
<Button size="sm" variant="ghost" onClick={() => { void statusQuery.refetch(); }}>Retry status</Button>
<Button size="sm" variant="outline" disabled><FlaskConical />{t("Test catalog default")}</Button>
<Button size="sm" variant="ghost" onClick={() => { void statusQuery.refetch(); }}>{t("Retry status")}</Button>
</div>
</div>
)}
</section>
<section aria-label="Session models" className="border-t border-border/70 pt-5">
<section aria-label={t("Session models")} 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">Session models</h3>
<p className="mt-1 text-sm text-muted-foreground">Models currently available for interactive sessions in this installation.</p>
<h3 className="font-heading text-sm font-semibold">{t("Session models")}</h3>
<p className="mt-1 text-sm text-muted-foreground">{t("Models currently available for interactive sessions in this installation.")}</p>
</div>
{modelsQuery.data && (
<span className="font-mono text-xs tabular-nums text-muted-foreground">{modelsQuery.data.models.length} available</span>
<span className="font-mono text-xs tabular-nums text-muted-foreground">{modelsQuery.data.models.length} {t("available")}</span>
)}
</div>
{modelsQuery.isLoading ? (
<p className="mt-4 text-sm text-muted-foreground">Loading session models…</p>
<p className="mt-4 text-sm text-muted-foreground">{t("Loading session models…")}</p>
) : modelsQuery.isError ? (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3" role="alert">
<p className="text-sm text-destructive">Could not load the session model catalog.</p>
<Button size="sm" variant="ghost" onClick={() => { void modelsQuery.refetch(); }}>Retry models</Button>
<p className="text-sm text-destructive">{t("Could not load the session model catalog.")}</p>
<Button size="sm" variant="ghost" onClick={() => { void modelsQuery.refetch(); }}>{t("Retry models")}</Button>
</div>
) : modelsQuery.data?.models.length ? (
<ul className="mt-4 divide-y divide-border/70 border-y border-border/70">
@@ -434,23 +442,23 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
<p className="truncate text-sm font-semibold" title={model.name}>{model.name}</p>
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground" title={model.provider + "/" + model.id}>{model.provider}/{model.id}</p>
</div>
{isDefault && <span className="shrink-0 rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground">Default</span>}
{isDefault && <span className="shrink-0 rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground">{t("Default")}</span>}
</li>
);
})}
</ul>
) : (
<p className="mt-4 text-sm text-muted-foreground">No session models are configured for this installation.</p>
<p className="mt-4 text-sm text-muted-foreground">{t("No session models are configured for this installation.")}</p>
)}
</section>
<section aria-label="Sanitized diagnostics" className="border-t border-border/70 pt-5">
<section aria-label={t("Sanitized diagnostics")} className="border-t border-border/70 pt-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div><h3 className="font-heading text-sm font-semibold">Sanitized diagnostics</h3><p className="mt-1 text-sm text-muted-foreground">Shows at most 200 recent lines with declared secret values removed.</p></div>
<Button size="sm" variant="outline" onClick={() => setLogsRequested(true)} disabled={logsQuery.isFetching}>{logsQuery.isFetching ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ScrollText />}Show sanitized logs</Button>
<div><h3 className="font-heading text-sm font-semibold">{t("Sanitized diagnostics")}</h3><p className="mt-1 text-sm text-muted-foreground">{t("Shows at most 200 recent lines with declared secret values removed.")}</p></div>
<Button size="sm" variant="outline" onClick={() => setLogsRequested(true)} disabled={logsQuery.isFetching}>{logsQuery.isFetching ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ScrollText />}{t("Show sanitized logs")}</Button>
</div>
{logsQuery.isError && <p role="alert" className="mt-3 text-sm text-destructive">Could not load sanitized Pi diagnostics.</p>}
{logsQuery.data && <pre aria-label="Sanitized Pi diagnostics" className="mt-3 max-h-48 overflow-auto rounded-md border border-border bg-muted/50 p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap">{logsQuery.data.lines.join("\n") || "No diagnostic lines are available."}</pre>}
{logsQuery.isError && <p role="alert" className="mt-3 text-sm text-destructive">{t("Could not load sanitized Pi diagnostics.")}</p>}
{logsQuery.data && <pre aria-label={t("Sanitized Pi diagnostics")} className="mt-3 max-h-48 overflow-auto rounded-md border border-border bg-muted/50 p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap">{logsQuery.data.lines.join("\n") || t("No diagnostic lines are available.")}</pre>}
</section>
</div>
)}
+8 -6
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../components/ui/dialog";
import { Button } from "../components/ui/button";
@@ -10,7 +11,8 @@ interface Props {
title?: string;
}
export function RenameDialog({ open, initial, onOpenChange, onSubmit, title = "Rename session" }: Props) {
export function RenameDialog({ open, initial, onOpenChange, onSubmit, title }: Props) {
const { t: translate } = useI18n();
const [name, setName] = useState(initial);
useEffect(() => { if (open) setName(initial); }, [open, initial]);
@@ -24,21 +26,21 @@ export function RenameDialog({ open, initial, onOpenChange, onSubmit, title = "R
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>{title ?? translate("Rename session")}</DialogTitle></DialogHeader>
<form onSubmit={submit} className="flex flex-col gap-3">
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="rename-name">Name</label>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="rename-name">{translate("Name")}</label>
<input
id="rename-name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm shadow-xs outline-none transition-[border-color,box-shadow] focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
placeholder="Session name…"
placeholder={translate("Session name…")}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="submit" size="sm">Save</Button>
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>{translate("Cancel")}</Button>
<Button type="submit" size="sm">{translate("Save")}</Button>
</DialogFooter>
</form>
</DialogContent>
@@ -1,4 +1,6 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { setLocale } from "../i18n";
import { http, HttpResponse } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw";
@@ -22,6 +24,24 @@ const base: SessionSummary = {
name: null, group: null, archived: false,
};
afterEach(() => act(() => setLocale("en")));
test("offers a localized retry after document loading fails and preserves document content", async () => {
server.use(http.get("/api/sessions/s1/documents", () => new HttpResponse(null, { status: 503 })));
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
expect(await screen.findByRole("alert")).toHaveTextContent("Session documents could not be loaded.");
act(() => setLocale("it"));
expect(screen.getByRole("alert")).toHaveTextContent("Impossibile caricare i documenti della sessione.");
server.use(http.get("/api/sessions/s1/documents", () => HttpResponse.json([
{ phase: "—", key: "question", title: "Original question", format: "text", content: "Choose a workspace" },
])));
await userEvent.click(screen.getByRole("button", { name: "Riprova" }));
expect(await screen.findByRole("heading", { name: "Domanda originale" })).toBeVisible();
expect(screen.getByText("Choose a workspace")).toBeVisible();
expect(screen.getByRole("button", { name: "Riprendi" })).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
beforeEach(() => {
server.use(http.get("/api/sessions/s1/documents", () =>
HttpResponse.json([
+22 -14
View File
@@ -1,3 +1,4 @@
import { useI18n, translate } from "../i18n";
import { useQuery } from "@tanstack/react-query";
import { X } from "lucide-react";
import { getSessionDocuments } from "../api/sessions";
@@ -17,9 +18,9 @@ interface Props {
}
function statusLabel(s: SessionSummary): string {
if (s.archived) return "Archived";
if (s.status === "finalized") return "Completed";
return `In progress · ${s.status}`;
if (s.archived) return translate("Archived");
if (s.status === "finalized") return translate("Completed");
return translate("In progress · {status}", { status: translate(s.status) });
}
type MemoryItem = {
@@ -46,6 +47,7 @@ const HIDDEN_DECISION_TYPES = new Set([
]);
function DocBody({ doc }: { doc: SessionDocument }) {
const { t: translate } = useI18n();
if (doc.format === "sql") return <SqlViewer blocks={[{ name: doc.title, sql: doc.content }]} />;
if (doc.format === "markdown") return <MarkdownView source={doc.content} />;
if (doc.format === "schema-linking") {
@@ -71,10 +73,10 @@ function DocBody({ doc }: { doc: SessionDocument }) {
})
: [];
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">No memories recorded.</p>;
return <p className="text-sm text-muted-foreground">{translate("No memories recorded.")}</p>;
}
return (
<ul aria-label="Memories" className="flex flex-col divide-y divide-border/50">
<ul aria-label={translate("Memories")} className="flex flex-col divide-y divide-border/50">
{items.map((item, index) => (
<li key={`${item.status}:${item.subject}:${index}`} className="py-4 first:pt-0">
<span className={[
@@ -83,7 +85,7 @@ function DocBody({ doc }: { doc: SessionDocument }) {
? "bg-[oklch(var(--success)/0.15)] text-[oklch(0.47_0.12_165)]"
: "bg-muted text-muted-foreground",
].join(" ")}>
{item.status === "approved" ? "Approved" : "Declined"}
{item.status === "approved" ? translate("Approved") : translate("Declined")}
</span>
<div className="mt-2 font-medium text-foreground">
<MarkdownView source={item.subject} />
@@ -114,13 +116,13 @@ function DocBody({ doc }: { doc: SessionDocument }) {
.filter((d): d is { type?: string; subject?: string; detail?: string } => (
d !== null && !HIDDEN_DECISION_TYPES.has(d.type ?? "")
));
if (items.length === 0) return <p className="text-sm text-muted-foreground">No decisions recorded.</p>;
if (items.length === 0) return <p className="text-sm text-muted-foreground">{translate("No decisions recorded.")}</p>;
return (
<ul className="flex flex-col divide-y divide-border/50">
{items.map((d, i) => (
<li key={i} className="py-3 first:pt-0">
<span className={`inline-block rounded-md px-2 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide ${decisionChipClass(d.type ?? "")}`}>
{(d.type ?? "decision").replace(/_/g, " ")}
{translate((d.type ?? "decision").replace(/_/g, " "))}
</span>
{d.subject && (
<div className="mt-1.5 text-sm font-medium text-foreground">
@@ -152,7 +154,8 @@ function decisionChipClass(type: string): string {
}
export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit = false, hidden = false }: Props) {
const { data: docs = [], isLoading } = useQuery<SessionDocument[]>({
const { t: translate } = useI18n();
const { data: docs = [], isLoading, isError, refetch } = useQuery<SessionDocument[]>({
queryKey: ["session-documents", session.id],
queryFn: () => getSessionDocuments(session.id),
});
@@ -160,7 +163,7 @@ export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit
return (
<aside
aria-label="Session summary"
aria-label={translate("Session summary")}
aria-hidden={hidden || undefined}
hidden={hidden}
className={[
@@ -179,16 +182,21 @@ export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit
</div>
<div className="flex shrink-0 items-center gap-1">
{resumable && (
<Button size="sm" variant="outline" onClick={() => onResume(session.id)}>Resume</Button>
<Button size="sm" variant="outline" onClick={() => onResume(session.id)}>{translate("Resume")}</Button>
)}
<Button size="icon-sm" variant="ghost" aria-label="Close panel" onClick={onClose}>
<Button size="icon-sm" variant="ghost" aria-label={translate("Close panel")} onClick={onClose}>
<X className="size-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-5">
{isLoading ? (
<p className="text-xs text-muted-foreground">Loading…</p>
<p className="text-xs text-muted-foreground">{translate("Loading…")}</p>
) : isError ? (
<div role="alert" className="space-y-2 text-sm text-destructive">
<p>{translate("Session documents could not be loaded.")}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>{translate("Retry")}</Button>
</div>
) : (
<div className="flex w-full flex-col gap-7">
{docs.map((doc, idx) => (
@@ -201,7 +209,7 @@ export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit
>
<div className="mb-3 flex items-center gap-2">
<h3 className="thot-label">
{doc.title}
{translate(doc.title)}
</h3>
{doc.phase !== "—" && (
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.6rem] font-medium text-muted-foreground">
+11 -9
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { Menu } from "@base-ui/react/menu";
import { MoreVertical } from "lucide-react";
import type { SessionSummary } from "../api/types";
@@ -18,13 +19,14 @@ const itemCls =
"flex w-full cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground";
export function SessionMenu({ session, groups, onResume, onView, onRename, onMove, onNewGroup, onArchiveToggle, onDelete }: Props) {
const { t: translate } = useI18n();
// Resume re-enters the workflow; only meaningful while the session is still live
// (the backend returns 409 for finalized/archived, matching this guard).
const canResume = session.status !== "finalized" && !session.archived;
return (
<Menu.Root>
<Menu.Trigger
aria-label="Session actions"
aria-label={translate("Session actions")}
className="rounded-md p-1 text-muted-foreground transition-opacity hover:bg-accent data-popup-open:opacity-100"
onClick={(e) => e.stopPropagation()}
>
@@ -34,12 +36,12 @@ export function SessionMenu({ session, groups, onResume, onView, onRename, onMov
<Menu.Positioner side="bottom" align="end" sideOffset={4}>
<Menu.Popup className="z-50 min-w-44 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
{canResume && (
<Menu.Item className={itemCls} onClick={onResume}>Resume</Menu.Item>
<Menu.Item className={itemCls} onClick={onResume}>{translate("Resume")}</Menu.Item>
)}
<Menu.Item className={itemCls} onClick={onView}>Split view</Menu.Item>
<Menu.Item className={itemCls} onClick={onRename}>Rename</Menu.Item>
<Menu.Item className={itemCls} onClick={onView}>{translate("Split view")}</Menu.Item>
<Menu.Item className={itemCls} onClick={onRename}>{translate("Rename")}</Menu.Item>
<Menu.SubmenuRoot>
<Menu.SubmenuTrigger className={itemCls}>Move to group ›</Menu.SubmenuTrigger>
<Menu.SubmenuTrigger className={itemCls}>{translate("Move to group ›")}</Menu.SubmenuTrigger>
<Menu.Portal>
<Menu.Positioner side="right" align="start">
<Menu.Popup className="z-50 min-w-44 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
@@ -47,18 +49,18 @@ export function SessionMenu({ session, groups, onResume, onView, onRename, onMov
<Menu.Item key={g} className={itemCls} onClick={() => onMove(g)}>{g}</Menu.Item>
))}
{session.group && (
<Menu.Item className={itemCls} onClick={() => onMove("")}>No group</Menu.Item>
<Menu.Item className={itemCls} onClick={() => onMove("")}>{translate("No group")}</Menu.Item>
)}
<Menu.Item className={itemCls} onClick={onNewGroup}>New group…</Menu.Item>
<Menu.Item className={itemCls} onClick={onNewGroup}>{translate("New group…")}</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.SubmenuRoot>
<Menu.Item className={itemCls} onClick={onArchiveToggle}>
{session.archived ? "Restore" : "Archive"}
{session.archived ? translate("Restore") : translate("Archive")}
</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item className={`${itemCls} text-destructive`} onClick={onDelete}>Delete</Menu.Item>
<Menu.Item className={`${itemCls} text-destructive`} onClick={onDelete}>{translate("Delete")}</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
+51 -2
View File
@@ -9,6 +9,55 @@ import { useSessionStore } from "../store/sessionStore";
import { workspacePreferences } from "../workspaces/preferences";
import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput";
import { setAuthState } from "../auth/authState";
import { setLocale } from "../i18n";
afterEach(() => act(() => setLocale("en")));
function wouldWarnBeforeReload() {
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
test.each([null, "s1"])("protects an unsent composer draft for session %s without storing text", async sessionId => {
const view = render(<SteerInput sessionId={sessionId} />);
expect(wouldWarnBeforeReload()).toBe(false);
await userEvent.type(screen.getByRole("textbox"), "Private draft {{patient}");
expect(wouldWarnBeforeReload()).toBe(true);
act(() => setLocale("it"));
expect(screen.getByRole("textbox", { name: sessionId ? "Indicazioni" : "Nuova domanda" }))
.toHaveValue("Private draft {patient}");
expect(screen.getByRole("button", { name: "Invia" })).toBeEnabled();
expect(wouldWarnBeforeReload()).toBe(true);
for (const storage of [localStorage, sessionStorage]) {
for (let index = 0; index < storage.length; index++) {
expect(storage.getItem(storage.key(index)!)).not.toContain("Private draft");
}
}
await userEvent.clear(screen.getByRole("textbox"));
expect(wouldWarnBeforeReload()).toBe(false);
await userEvent.type(screen.getByRole("textbox"), " ");
expect(wouldWarnBeforeReload()).toBe(false);
await userEvent.type(screen.getByRole("textbox"), "Unsent");
view.unmount();
expect(wouldWarnBeforeReload()).toBe(false);
});
test("retains the reload guard after a failed send and removes it after successful retry", async () => {
server.use(http.post("/api/sessions/s1/steer", () => new HttpResponse(null, { status: 503 })));
render(<SteerInput sessionId="s1" />);
await userEvent.type(screen.getByRole("textbox"), "Keep my steering");
await userEvent.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByRole("alert")).toHaveTextContent("The service is temporarily unavailable. Please retry.");
expect(wouldWarnBeforeReload()).toBe(true);
act(() => setLocale("it"));
expect(screen.getByRole("alert")).toHaveTextContent("Il servizio è temporaneamente non disponibile. Riprova.");
server.use(http.post("/api/sessions/s1/steer", () => new HttpResponse(null, { status: 204 })));
await userEvent.click(screen.getByRole("button", { name: "Invia" }));
await waitFor(() => expect(screen.getByRole("textbox")).toHaveValue(""));
expect(wouldWarnBeforeReload()).toBe(false);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
const userA = {
issuer: "local", subject: "user-a", roles: ["user"] as const,
@@ -52,7 +101,7 @@ test("new sessions send the ephemeral workspace, model, provider, and thinking",
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await waitFor(() => expect(body).toEqual({
question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high",
question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high", interactionLanguage: "en",
}));
});
@@ -360,7 +409,7 @@ test("switching workspaces preserves a globally available catalog model", async
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await waitFor(() => expect(body).toEqual({
question: "q", workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
question: "q", workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", interactionLanguage: "en",
}));
});
+40 -18
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
// frontend/src/shell/SteerInput.tsx
import { useEffect, useRef, useState } from "react";
import { CornerDownLeft } from "lucide-react";
@@ -46,9 +47,21 @@ export function SteerInput({
awaitingInput = false,
working = false,
}: Props) {
const { t: translate } = useI18n();
const context = useWorkingContextScope();
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
useEffect(() => {
if (!text.trim()) return;
const protectDraft = (event: BeforeUnloadEvent) => {
event.preventDefault();
// Browsers display their own localized warning, not application copy.
event.returnValue = "";
};
window.addEventListener("beforeunload", protectDraft);
return () => window.removeEventListener("beforeunload", protectDraft);
}, [text]);
const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);
const setPhase = useSessionStore((s) => s.setPhase);
const taRef = useRef<HTMLTextAreaElement | null>(null);
@@ -59,6 +72,7 @@ export function SteerInput({
// before it can write the old session's completion into the new target.
operationEpochRef.current += 1;
setBusy(false);
setSendError(null);
}, [sessionId]);
// Merge our own ref (for autosizing) with the forwarded one (parent focus).
@@ -83,12 +97,17 @@ export function SteerInput({
const operation = captureAuthOperation({ sessionId, disposalEpoch: operationEpochRef.current });
if (!operation) return;
setBusy(true);
setSendError(null);
if (sessionId) {
try {
await postSteer(sessionId, trimmed);
if (!isAuthOperationCurrent(operation, { sessionId, disposalEpoch: operationEpochRef.current })) return;
setLastUserEntry({ kind: "input", text: trimmed });
setText("");
} catch (error) {
if (isAuthOperationCurrent(operation, { sessionId, disposalEpoch: operationEpochRef.current })) {
setSendError(apiErrorMessage(error));
}
} finally {
if (isAuthOperationCurrent(operation, { sessionId, disposalEpoch: operationEpochRef.current })) {
setBusy(false);
@@ -137,14 +156,14 @@ export function SteerInput({
}
}
return (
return (<>
<div className="flex items-end gap-2">
{sessionId && (
<button
type="button"
onClick={onStop}
title="Stop & save session"
aria-label="Stop and save session"
title={translate("Stop & save session")}
aria-label={translate("Stop and save session")}
className="grid size-7 shrink-0 place-items-center rounded-md border border-border text-primary transition-colors hover:bg-muted"
>
<span
@@ -159,8 +178,8 @@ export function SteerInput({
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={sessionId ? "Free steering (!)…" : "Ask a question to start a new session…"}
aria-label={sessionId ? "Steering" : "New question"}
placeholder={sessionId ? translate("Free steering (!)…") : translate("Ask a question to start a new session…")}
aria-label={sessionId ? translate("Steering") : translate("New question")}
data-awaiting-input={awaitingInput ? "true" : undefined}
className={["max-h-40 flex-1 resize-none rounded-lg bg-card px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground", awaitingInput && "thot-awaiting-input"].filter(Boolean).join(" ")}
/>
@@ -168,14 +187,15 @@ export function SteerInput({
type="button"
onClick={submit}
disabled={!text.trim() || busy || Boolean(context && (!context.ready || context.administrationBusy))}
aria-label="Send"
title="Send"
aria-label={translate("Send")}
title={translate("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"
>
<CornerDownLeft className="size-3.5" />
</button>
</div>
);
{sendError && <p role="alert" className="mt-1 text-sm text-destructive">{translate(sendError)}</p>}
</>);
}
/**
@@ -183,6 +203,7 @@ export function SteerInput({
* Right: live context usage plus ephemeral workspace/model/thinking selectors.
*/
export function ComposerFooter() {
const { t: translate } = useI18n();
const globalContext = useWorkingContextScope();
const interactionBusy = useInteractionModelBusy();
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings });
@@ -264,9 +285,9 @@ 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">
{!globalContext && <FooterSelect aria-label="Workspace" value={workspace} disabled={interactionBusy} onChange={(v) => update({ workspaceId: v })}>
{!globalContext && <FooterSelect aria-label={translate("Workspace")} value={workspace} disabled={interactionBusy} onChange={(v) => update({ workspaceId: v })}>
{workspaces.length === 0 ? (
<option value="">— workspace —</option>
<option value="">{translate("— workspace —")}</option>
) : (
workspaces.filter((w) => w.revision).map((w) => (
<option key={w.id} value={w.id}>
@@ -277,19 +298,19 @@ export function ComposerFooter() {
</FooterSelect>}
<span
className="whitespace-nowrap font-mono text-[0.7rem] tabular-nums text-muted-foreground"
aria-label="Input, cached, and output tokens"
aria-label={translate("Input, cached, and output tokens")}
>
{formatTokenUsage(tokenUsage)}
</span>
</div>
<div className="flex items-center gap-2">
{!globalContext && <FooterSelect aria-label="Model" value={model} disabled={interactionBusy} onChange={onModelChange}>
{!globalContext && <FooterSelect aria-label={translate("Model")} value={model} disabled={interactionBusy} onChange={onModelChange}>
{policyModels.length === 0 ? (
<option value={model}>{model || "— model —"}</option>
<option value={model}>{model || translate("— model —")}</option>
) : (
<>
{showModelFallback && <option value={model}>{model || "— model —"}</option>}
{showModelFallback && <option value={model}>{model || translate("— model —")}</option>}
{policyModels.map((m) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
{m.name}
@@ -298,10 +319,10 @@ export function ComposerFooter() {
</>
)}
</FooterSelect>}
<FooterSelect aria-label="Thinking level" value={thinking} disabled={interactionBusy} onChange={(v) => update({ thinking: v })} className="capitalize">
<FooterSelect aria-label={translate("Thinking level")} value={thinking} disabled={interactionBusy} onChange={(v) => update({ thinking: v })} className="capitalize">
{THINKING_LEVELS.map((l) => (
<option key={l} value={l}>
{l}
{translate(l)}
</option>
))}
</FooterSelect>
@@ -355,6 +376,7 @@ export function formatTokenUsage(usage: {
/** Circular context-usage gauge. `pct` in [0,1]; 0 renders an empty track. */
export function ContextGauge({ pct = 0 }: { pct?: number }) {
const { t: translate } = useI18n();
const r = 6;
const circ = 2 * Math.PI * r;
const clamped = Math.min(Math.max(pct, 0), 1);
@@ -366,8 +388,8 @@ export function ContextGauge({ pct = 0 }: { pct?: number }) {
: "stroke-red-500";
return (
<span
title="Context usage"
aria-label={`Context usage ${Math.round(clamped * 100)}%`}
title={translate("Context usage")}
aria-label={translate("Context usage {percent}%", { percent: Math.round(clamped * 100) })}
data-level={level}
className="inline-flex items-center"
>
+6 -5
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
import { Button } from "../components/ui/button";
@@ -8,16 +9,16 @@ interface Props {
}
export function StopConfirmDialog({ open, onOpenChange, onConfirm }: Props) {
const { t: translate } = useI18n();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>Stop the session?</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>{translate("Stop the session?")}</DialogTitle></DialogHeader>
<DialogDescription>
The running process will be interrupted. Progress so far is saved, and you can resume the session later.
</DialogDescription>
{translate("The running process will be interrupted. Progress so far is saved, and you can resume the session later.")}</DialogDescription>
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>Stop &amp; save</Button>
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>{translate("Cancel")}</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>{translate("Stop & save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,25 @@
import { act, render, screen } from "@testing-library/react";
import { WidgetHost } from "./WidgetHost";
import { useSessionStore } from "../store/sessionStore";
import { setLocale } from "../i18n";
afterEach(() => {
act(() => { setLocale("en"); useSessionStore.getState().resetSession(); });
});
test("gate chrome follows UI locale while model questions and choices remain in their session language", () => {
useSessionStore.setState({ pendingWidget: {
id: "approval", widget: "select", title: "Domanda del revisore",
options: [
{ id: "approve", label: "Salva e procedi", label_i18n: "Save and proceed" },
{ id: "proposal", label: "Usa la data del ricovero" },
],
} });
render(<WidgetHost sessionId="s1" />);
expect(screen.getByRole("button", { name: "Save and proceed" })).toBeInTheDocument();
expect(screen.getByText("Domanda del revisore")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Usa la data del ricovero" })).toBeInTheDocument();
act(() => setLocale("it"));
expect(screen.getByRole("button", { name: "Salva e procedi" })).toBeInTheDocument();
expect(useSessionStore.getState().pendingWidget?.options?.[0].label).toBe("Salva e procedi");
});
+55 -7
View File
@@ -1,15 +1,23 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
import { setAuthState } from "../auth/authState";
import { WidgetHost } from "./WidgetHost";
import { useSessionStore } from "../store/sessionStore";
import type { WidgetDescriptor } from "../api/types";
// Replace the widget registry with a renderer that always throws, simulating a
// viewer/widget that crashes on malformed gate data (the real white-screen path).
vi.mock("../widgets", () => ({
resolve: () => function Exploding() {
throw new Error("bad gate payload");
},
}));
vi.mock("../widgets", async (importOriginal) => {
const widgets = await importOriginal<typeof import("../widgets")>();
return {
...widgets,
resolve: (kind: string) => kind === "crashing-test" ? function Exploding() {
throw new Error("bad gate payload");
} : widgets.resolve(kind),
};
});
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
@@ -22,7 +30,7 @@ afterEach(() => {
act(() => useSessionStore.getState().resetSession());
});
const gate: WidgetDescriptor = { id: "g1", widget: "select", title: "Choose" };
const gate: WidgetDescriptor = { id: "g1", widget: "crashing-test", title: "Choose" };
test("a crashing widget shows a fallback instead of unmounting the app", async () => {
useSessionStore.setState({ pendingWidget: gate });
@@ -33,3 +41,43 @@ test("a crashing widget shows a fallback instead of unmounting the app", async (
// The store (and thus the surrounding app) is still alive and interactive.
expect(useSessionStore.getState().pendingWidget).toEqual(gate);
});
test("keeps answer drafts protected during a failed POST and retry, then clears the warning on success", async () => {
setAuthState({ issuer: "local", subject: "reviewer", roles: ["user"], permissions: ["session.use"],
isAdmin: false, csrfToken: "a".repeat(43), session: null });
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
let requests = 0;
server.use(http.post("/api/sessions/s1/response", async () => {
requests += 1;
if (requests === 1) {
await held;
return HttpResponse.json({ error: "Temporary failure" }, { status: 500 });
}
return new HttpResponse(null, { status: 204 });
}));
const reloadIsBlocked = () => {
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
};
useSessionStore.setState({ pendingWidget: { id: "answer", widget: "freetext" } });
render(<WidgetHost sessionId="s1" />);
expect(reloadIsBlocked()).toBe(false);
await userEvent.type(screen.getByRole("textbox"), "Unsaved answer");
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Send" }));
expect(screen.getByRole("status")).toHaveTextContent("Sending response");
try {
expect(reloadIsBlocked()).toBe(true);
} finally {
release();
}
await waitFor(() => expect(screen.getByRole("button", { name: "Send" })).toBeEnabled());
expect(screen.getByRole("textbox")).toHaveValue("Unsaved answer");
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Send" }));
await waitFor(() => expect(screen.queryByRole("textbox")).not.toBeInTheDocument());
expect(requests).toBe(2);
expect(reloadIsBlocked()).toBe(false);
});
+10 -7
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useEffect, useRef, useState } from "react";
import { useSessionStore } from "../store/sessionStore";
import { resolve } from "../widgets";
@@ -5,9 +6,12 @@ import { postResponse } from "../api/sessions";
import type { UiResponse } from "../api/types";
import { ErrorBoundary } from "../components/ErrorBoundary";
import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperation";
import { localizeWidget } from "../widgets/localize";
export function WidgetHost({ sessionId }: { sessionId: string | null }) {
const pending = useSessionStore((s) => s.pendingWidget);
const { t: translate } = useI18n();
const source = useSessionStore((s) => s.pendingWidget);
const pending = source ? localizeWidget(source, translate) : null;
const clearPending = useSessionStore((s) => s.clearPending);
const pushToast = useSessionStore((s) => s.pushToast);
const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);
@@ -41,8 +45,8 @@ export function WidgetHost({ sessionId }: { sessionId: string | null }) {
level: "error",
text:
err instanceof Error
? `Failed to send response: ${err.message}`
: "Failed to send response.",
? translate("Failed to send response: {error}", { error: translate(err.message) })
: translate("Failed to send response."),
});
responseInFlight.current = false;
setResponding(false);
@@ -56,7 +60,7 @@ export function WidgetHost({ sessionId }: { sessionId: string | null }) {
kind: "choice",
text:
r.kind === "schema-linking"
? `${r.tables?.filter((t) => t.enacted).length ?? 0} tables curated`
? translate("{count} tables curated", { count: r.tables?.filter((t) => t.enacted).length ?? 0 })
: r.text ?? r.choices?.join(", ") ?? r.decision?.type ?? r.control ?? "(choice)",
});
clearPending();
@@ -64,15 +68,14 @@ export function WidgetHost({ sessionId }: { sessionId: string | null }) {
// A malformed gate payload must not white-screen the whole app: isolate the
// widget render. resetKeys on the descriptor id so the next gate starts clean.
return (
<ErrorBoundary resetKeys={[pending.id]} label="step">
<ErrorBoundary resetKeys={[pending.id]} label={translate("step")}>
<div className="space-y-2">
<fieldset disabled={responding} className="min-w-0 border-0 p-0">
<Renderer descriptor={pending} onRespond={onRespond} sessionId={sessionId ?? undefined} />
</fieldset>
{responding && (
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
Sending response…
</p>
{translate("Sending response…")}</p>
)}
</div>
</ErrorBoundary>
+5 -3
View File
@@ -12,6 +12,7 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { useI18n } from "../i18n";
import { Button } from "../components/ui/button";
@@ -148,7 +149,7 @@ export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
title,
description,
onClose,
closeLabel = "Close panel",
closeLabel,
children,
bodyClassName,
footer,
@@ -160,6 +161,7 @@ export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
},
forwardedRef,
) {
const { t: translate } = useI18n();
const panelHost = useContext(WorkAreaPanelHostContext);
const titleId = useId();
const descriptionId = useId();
@@ -236,8 +238,8 @@ export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
data-drawer-close
onClick={onClose}
disabled={busy}
aria-label={closeLabel}
title={closeLabel}
aria-label={closeLabel ?? translate("Close panel")}
title={closeLabel ?? translate("Close panel")}
>
<X aria-hidden="true" />
</Button>
+17 -1
View File
@@ -1,9 +1,25 @@
// frontend/src/shell/WorkflowBar.test.tsx
import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import { setLocale } from "../i18n";
import { WorkflowBar } from "./WorkflowBar";
import { useSessionStore } from "../store/sessionStore";
beforeEach(() => useSessionStore.getState().resetSession());
afterEach(() => act(() => setLocale("en")));
test("updates phase names, state and timer accessibility live when the locale changes", () => {
useSessionStore.getState().setPhase("F4");
render(<WorkflowBar />);
expect(screen.getByText("Linking the schema")).toBeVisible();
act(() => setLocale("it"));
expect(screen.getByRole("navigation", { name: "Avanzamento del workflow" })).toBeVisible();
expect(screen.getByText("Collegamento dello schema")).toBeVisible();
expect(screen.getByLabelText("F4 Collegamento dello schema: in corso")).toHaveTextContent("F4");
expect(screen.getByLabelText("Tempo di elaborazione")).toBeVisible();
expect(useSessionStore.getState().currentPhase).toBe("F4");
act(() => setLocale("en"));
expect(screen.getByLabelText("F4 Schema linking — running")).toBeVisible();
});
test("highlights the phase matching currentPhase", () => {
useSessionStore.getState().setPhase("F4");
+17 -18
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
// frontend/src/shell/WorkflowBar.tsx
import { useSessionStore } from "../store/sessionStore";
import { ElapsedTimer } from "./ElapsedTimer";
@@ -16,19 +17,16 @@ const PHASES = [
{ id: "F8", name: "Datamart" },
];
// Synthetic, human-readable title for the current phase, shown under the dots.
// Static bilingual strings (no LLM): PHASE_LANG picks which set is displayed.
// The rest of the app chrome is English, so the label follows suit.
const PHASE_LANG: "en" | "it" = "en";
const PHASE_TITLES: Record<string, { en: string; it: string }> = {
F1: { en: "Clarifying the question", it: "Chiarimento della domanda" },
F2: { en: "Recalling relevant memory", it: "Recupero delle memorie utili" },
F3: { en: "Rewriting the question", it: "Riscrittura della domanda" },
F4: { en: "Linking the schema", it: "Collegamento dello schema" },
F5: { en: "Planning the SQL", it: "Pianificazione della query" },
F6: { en: "Building the CTEs", it: "Costruzione delle CTE" },
F7: { en: "Finalizing the SQL", it: "Finalizzazione della query" },
F8: { en: "Building the datamart", it: "Costruzione del datamart" },
// Message IDs stay locale-neutral until render, including for an active session.
const PHASE_TITLES: Record<string, string> = {
F1: "Clarifying the question",
F2: "Recalling relevant memory",
F3: "Rewriting the question",
F4: "Linking the schema",
F5: "Planning the SQL",
F6: "Building the CTEs",
F7: "Finalizing the SQL",
F8: "Building the datamart",
};
type DotState = "done" | "running" | "error" | "pending";
@@ -54,18 +52,19 @@ export function WorkflowBar({
/** Active session id — remounts the timer (resets it) when the session changes. */
resetKey?: string | null;
}) {
const { t: translate } = useI18n();
const currentPhase = useSessionStore((s) => s.currentPhase);
const phaseError = useSessionStore((s) => s.phaseError);
const activeIdx = PHASES.findIndex((p) => p.id === currentPhase);
// On a finalized session all phases are done — surface the closing phase's title.
const titleId = finalized ? "F8" : currentPhase;
const phaseTitle = titleId ? PHASE_TITLES[titleId]?.[PHASE_LANG] : null;
const phaseTitle = titleId ? PHASE_TITLES[titleId] : null;
return (
<div className="flex flex-col items-center gap-1">
<div className="flex items-center justify-center gap-3">
<nav
aria-label="Workflow progress"
aria-label={translate("Workflow progress")}
className="flex items-center gap-0 overflow-x-auto py-0.5"
>
{PHASES.map((p, i) => {
@@ -103,8 +102,8 @@ export function WorkflowBar({
data-testid={`phase-${p.id}`}
data-active={isActive ? "true" : "false"}
data-state={state}
title={`${p.id}: ${p.name}`}
aria-label={`${p.id} ${p.name} — ${state}`}
title={`${p.id}: ${translate(p.name)}`}
aria-label={translate("{phase} {name} — {state}", { phase: p.id, name: translate(p.name), state: translate(state) })}
aria-current={isActive ? "step" : undefined}
className={[
"relative inline-flex size-6 items-center justify-center rounded-full",
@@ -127,7 +126,7 @@ export function WorkflowBar({
data-testid="phase-title"
className="thot-label"
>
{phaseTitle}
{translate(phaseTitle)}
</span>
)}
</div>
+18 -16
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useState } from "react";
import { ChevronDown, Cpu, Layers, LockKeyhole, SlidersHorizontal } from "lucide-react";
import { Button } from "../components/ui/button";
@@ -8,33 +9,34 @@ export function WorkingContextShelf({ context, locked, onWorkspace, onModel }: {
context: ReturnType<typeof useWorkingContext>; locked: boolean;
onWorkspace: (id: string) => void; onModel: (id: string) => void;
}) {
const { t: translate } = useI18n();
const [expanded, setExpanded] = useState(false);
const open = expanded || !context.ready;
return <section className="thot-context-shelf" aria-label="Global working context">
return <section className="thot-context-shelf" aria-label={translate("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>
<span className="thot-context-summary"><small>{translate("WORKING CONTEXT")}</small><strong>{context.workspace?.displayName ?? translate("Choose workspace")}<span aria-hidden> / </span>{context.model?.name ?? translate("Choose model")}</strong></span>
<span className="thot-context-action">{locked && <LockKeyhole size={15} aria-hidden />}<span>{locked ? translate("Locked") : open ? translate("Close") : translate("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>
<p className="thot-context-explanation">{translate("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><span><Layers size={15} aria-hidden />{translate("Workspace")}</span>
<select aria-label={translate("Workspace")} value={context.workspace ? context.workspaceId : ""} disabled={locked || context.loading} onChange={event => onWorkspace(event.target.value)}>
<option value="" disabled>{translate("Choose a workspace")}</option>{context.workspaces.map(workspace => <option key={workspace.id} value={workspace.id}>{workspace.displayName}</option>)}
</select><small>{translate(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><span><Cpu size={15} aria-hidden />{translate("AI interaction model")}</span>
<select aria-label={translate("Model")} value={context.model ? context.modelId : ""} disabled={locked || context.loading} onChange={event => onModel(event.target.value)}>
<option value="" disabled>{translate("Choose a model")}</option>{context.models.map(model => <option key={`${model.provider}/${model.id}`} value={`${model.provider}/${model.id}`}>{model.name}</option>)}
</select><small>{translate(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>
{locked && <p role="status">{translate("Workspace and model are locked until the current operation finishes.")}</p>}
{context.ready && context.error && <p role="status">{translate("Context refresh failed. Your current choices and unsaved work have been retained.")}</p>}
{!context.ready && <p role="status">{context.loading ? translate("Loading working context…") : context.error ? translate("Working context is unavailable. Retry or ask the installation operator to check configuration.") : translate("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}>{translate("Retry context")}</Button>}{context.ready && <Button variant="outline" onClick={() => setExpanded(false)}>{translate("Done")}</Button>}</div>
</div>
</section>;
}
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import { setLocale } from "../i18n";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { beforeEach, expect, test, vi } from "vitest";
@@ -136,6 +137,21 @@ test("defaults workspace mutations and secrets to denied", async () => {
expect(screen.queryByRole("heading", { name: "Runtime secrets" })).not.toBeInTheDocument();
});
test("switches workspace controls and runtime secret help in place", async () => {
renderManager();
await userEvent.click(await screen.findByRole("button", { name: "PSD Clinical" }));
await screen.findByRole("button", { name: "Validate workspace source" });
try {
act(() => setLocale("it"));
expect(screen.getByRole("button", { name: "Valida origine del workspace" })).toBeVisible();
expect(screen.getByRole("button", { name: "Verifica connessioni del workspace" })).toBeVisible();
expect(screen.getByRole("heading", { name: "Segreti runtime" })).toBeVisible();
act(() => setLocale("en"));
expect(screen.getByRole("button", { name: "Validate workspace source" })).toBeVisible();
expect(screen.getByRole("heading", { name: "Runtime secrets" })).toBeVisible();
} finally { act(() => setLocale("en")); }
});
test("page tabs group workspace areas and preserve unsaved secrets across tabs", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}>
+81 -89
View File
@@ -1,3 +1,5 @@
import { useI18n, translate } from "../i18n";
import { renderAdminDiagnostic } from "./database-management/messages";
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -44,10 +46,11 @@ function QueryError({ name, message, retryLabel, onRetry }: {
retryLabel: string;
onRetry: () => void;
}) {
const { t } = useI18n();
return (
<div role="alert" aria-label={name} className="grid gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
<p>{message}</p>
<div><Button size="sm" variant="outline" onClick={onRetry}>{retryLabel}</Button></div>
<div role="alert" aria-label={t(name)} className="grid gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
<p>{t(message)}</p>
<div><Button size="sm" variant="outline" onClick={onRetry}>{t(retryLabel)}</Button></div>
</div>
);
}
@@ -58,7 +61,7 @@ function publicError(error: unknown, fallback: string): string {
}
function stateLabel(state: "ready" | "configuration_required"): string {
return state === "ready" ? "Ready" : "Runtime configuration required";
return state === "ready" ? translate("Ready") : translate("Runtime configuration required");
}
const workspaceAuthoringGuideUrl =
@@ -98,6 +101,7 @@ export function WorkspaceManager({
canManageWorkspace?: boolean;
canManageSecrets?: boolean;
}) {
const { t } = useI18n();
const workingContext = useWorkingContextScope();
const tabsId = useId();
const [activeArea, setActiveArea] = useState<typeof workspaceAreas[number]>("Preparation");
@@ -232,7 +236,7 @@ export function WorkspaceManager({
const workspaces = (workspacesQuery.data ?? []).filter(workspace => !workingContext || workspace.id === workingContext.workspaceId);
const selectedSummary = useMemo(
() => workspaces.find(({ id }) => id === selectedId),
[selectedId, workspaces],
[selectedId, workspaces, t],
);
const detailQuery = useQuery({
queryKey: ["workspace", selectedId],
@@ -251,13 +255,13 @@ export function WorkspaceManager({
};
const selectWorkspace = (id: string) => {
if (page && (busyAction || (hasEnteredSecrets && !window.confirm("Discard unsaved workspace secrets?")))) return;
if (page && (busyAction || (hasEnteredSecrets && !window.confirm(t("Discard unsaved workspace secrets?"))))) return;
invalidateWorkspaceContext();
setSelectedId(id);
};
const showLevelOne = () => {
if (page && (busyAction || (hasEnteredSecrets && !window.confirm("Discard unsaved workspace secrets?")))) return;
if (page && (busyAction || (hasEnteredSecrets && !window.confirm(t("Discard unsaved workspace secrets?"))))) return;
invalidateWorkspaceContext();
setSelectedId(undefined);
};
@@ -409,7 +413,7 @@ export function WorkspaceManager({
const repository = statusQuery.data?.repository;
const repositoryLabel = repository
? `${repository.host}/${repository.repository}`
: "the repository configured for this ThothII installation";
: t("the repository configured for this ThothII installation");
const repositoryWebUrl = repository && repository.transport !== "local"
? `https://${repository.host}/${repository.repository}`
: undefined;
@@ -424,17 +428,17 @@ export function WorkspaceManager({
<WorkAreaPanel
open={open}
mode={page ? "page" : "panel"}
ariaLabel="Workspace management"
ariaLabel={t("Workspace management")}
width="expanded"
eyebrow="Workspace configuration"
title="Workspace management"
description="Read, validate, and complete the runtime configuration of workspaces supplied by the installation repository."
eyebrow={t("Workspace configuration")}
title={t("Workspace management")}
description={t("Read, validate, and complete the runtime configuration of workspaces supplied by the installation repository.")}
onClose={close}
closeLabel="Close workspace management"
closeLabel={t("Close workspace management")}
bodyClassName="thot-work-area-panel__body--flush"
>
<div className="thot-workspace-manager__layout">
<nav aria-label="Workspaces" className="thot-workspace-manager__navigation flex min-h-0 flex-col bg-muted/30 p-3">
<nav aria-label={t("Workspaces")} className="thot-workspace-manager__navigation flex min-h-0 flex-col bg-muted/30 p-3">
<Button
type="button"
size="sm"
@@ -442,13 +446,13 @@ export function WorkspaceManager({
className="mb-3 w-full justify-start"
onClick={showLevelOne}
>
<ArrowLeft />{page ? "Repository overview" : "Back to Level 1"}
<ArrowLeft />{page ? t("Repository overview") : t("Back to Level 1")}
</Button>
<p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Available workspaces</p>
<p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t("Available workspaces")}</p>
<div className="min-h-0 flex-1 overflow-y-auto">
{workspacesQuery.isLoading && <p className="p-2 text-xs text-muted-foreground">Loading workspaces…</p>}
{workspacesQuery.isLoading && <p className="p-2 text-xs text-muted-foreground">{t("Loading workspaces…")}</p>}
{workspacesQuery.isError && (
<QueryError name="Workspace list failed" message="Could not load workspaces." retryLabel="Retry workspace list" onRetry={() => { void workspacesQuery.refetch(); }} />
<QueryError name="Workspace list failed" message={t("Could not load workspaces.")} retryLabel={t("Retry workspace list")} onRetry={() => { void workspacesQuery.refetch(); }} />
)}
{workspaces.map((workspace) => (
<button
@@ -461,26 +465,26 @@ export function WorkspaceManager({
>
<span className="block truncate">{workspace.displayName}</span>
<span className="block truncate text-xs text-muted-foreground">{workspace.id}</span>
<span className="mt-1 block text-[0.7rem] text-muted-foreground">{stateLabel(workspace.configurationState)}</span>
<span className="mt-1 block text-[0.7rem] text-muted-foreground">{t(stateLabel(workspace.configurationState))}</span>
</button>
))}
</div>
</nav>
<section
aria-label="Workspace configuration"
aria-label={t("Workspace configuration")}
className="thot-workspace-manager__content min-h-0 min-w-0 overflow-y-auto overscroll-contain px-6 py-5"
>
{notice && (
<p role="status" className="mb-4 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm">
<CheckCircle2 className="size-4 text-primary" />{notice}
<CheckCircle2 className="size-4 text-primary" />{t(notice)}
</p>
)}
{diagnostics.length > 0 && (
<div data-testid="workspace-diagnostics" aria-live="polite" className="mb-4 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
{diagnostics.map((diagnostic) => (
<p key={diagnostic} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{diagnostic}
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{renderAdminDiagnostic(t, diagnostic)}
</p>
))}
</div>
@@ -489,63 +493,58 @@ export function WorkspaceManager({
{!selectedSummary ? (
<section data-testid="workspace-overview" className="mx-auto max-w-3xl space-y-5">
<div>
<p className="thot-label">Repository</p>
<h2 className="font-heading text-xl font-semibold">What workspace files tell ThothII</h2>
<p className="thot-label">{t("Repository")}</p>
<h2 className="font-heading text-xl font-semibold">{t("What workspace files tell ThothII")}</h2>
</div>
<div className="grid gap-3 text-sm leading-6 text-muted-foreground">
<p>
A workspace file describes one data environment. Its <code>workspace.yaml</code> gives ThothII the workspace identity and language, plus any Evidence sources that support analysis. Database metadata and connection settings are managed separately in the PostgreSQL Catalog.
</p>
{t("A workspace file describes one data environment. Its")} <code>workspace.yaml</code> {t("gives ThothII the workspace identity and language, plus any Evidence sources that support analysis. Database metadata and connection settings are managed separately in the PostgreSQL Catalog.")}</p>
<p>
One Git repository can contain many workspaces. At its root, <code>thoth-workspaces.yaml</code> lists them, and each workspace has its own directory and <code>workspace.yaml</code>. This keeps workspace identity and supporting Evidence distinct while versioning them together.
</p>
{t("One Git repository can contain many workspaces. At its root,")} <code>thoth-workspaces.yaml</code> {t("lists them, and each workspace has its own directory and")} <code>workspace.yaml</code>{t(". This keeps workspace identity and supporting Evidence distinct while versioning them together.")}</p>
</div>
<div>
<h3 className="font-heading font-semibold text-foreground">How this installation uses the repository</h3>
<h3 className="font-heading font-semibold text-foreground">{t("How this installation uses the repository")}</h3>
<ol className="mt-1 grid list-decimal gap-0 pl-5 text-sm leading-6 text-muted-foreground">
<li>
The repository address, branch, and read-only Git credentials are configured during ThothII installation. This installation reads{" "}
{t("The repository address, branch, and read-only Git credentials are configured during ThothII installation. This installation reads")}{" "}
{repositoryWebUrl ? (
<a href={repositoryWebUrl} target="_blank" rel="noreferrer" className="break-all font-medium text-foreground underline underline-offset-4 hover:text-primary">{repositoryLabel}</a>
) : (
<span className="font-medium text-foreground">{repositoryLabel}</span>
)}{" "}
on branch <span className="font-mono text-foreground">{statusQuery.data?.branch ?? "main"}</span>.
{t("on branch")} <span className="font-mono text-foreground">{statusQuery.data?.branch ?? "main"}</span>.
</li>
<li>ThothII fetches the configured branch into its managed read-only checkout, reads the workspaces listed in the repository, and validates the complete candidate revision before activating it.</li>
<li>The active revision stays read-only. ThothII never edits, commits, pushes, or publishes workspace files; if validation fails, the previous valid revision stays active.</li>
<li>{t("ThothII fetches the configured branch into its managed read-only checkout, reads the workspaces listed in the repository, and validates the complete candidate revision before activating it.")}</li>
<li>{t("The active revision stays read-only. ThothII never edits, commits, pushes, or publishes workspace files; if validation fails, the previous valid revision stays active.")}</li>
</ol>
</div>
<p className="text-sm leading-6 text-muted-foreground">
The <a href={workspaceAuthoringGuideUrl} target="_blank" rel="noreferrer" className="font-medium text-foreground underline underline-offset-4 hover:text-primary">workspace authoring instructions on GitHub</a> explain the required layout and validation rules for people who maintain the repository.
</p>
{t("The")} <a href={workspaceAuthoringGuideUrl} target="_blank" rel="noreferrer" className="font-medium text-foreground underline underline-offset-4 hover:text-primary">{t("workspace authoring instructions on GitHub")}</a> {t("explain the required layout and validation rules for people who maintain the repository.")}</p>
{canManageWorkspace && <div className="rounded-lg border border-border bg-muted/30 p-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-2xl">
<h3 className="font-heading font-semibold">Update workspace repository</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">Fetches the configured branch directly into the managed read-only checkout and validates it. No workspace selection is required. If candidate validation fails, the current active revision remains unchanged.</p>
<h3 className="font-heading font-semibold">{t("Update workspace repository")}</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{t("Fetches the configured branch directly into the managed read-only checkout and validates it. No workspace selection is required. If candidate validation fails, the current active revision remains unchanged.")}</p>
</div>
<Button disabled={busyAction === "repository"} onClick={() => { void updateRepository(); }}>
<GitPullRequest />Update workspace repository
</Button>
<GitPullRequest />{t("Update workspace repository")}</Button>
</div>
</div>}
{!canManageWorkspace && (
<p className="rounded-lg border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
You can inspect workspaces. Workspace updates, validation, and connection tests require workspace management permission.
</p>
{t("You can inspect workspaces. Workspace updates, validation, and connection tests require workspace management permission.")}</p>
)}
<p className="text-sm leading-6 text-muted-foreground">Selecting a workspace from the left opens its own revision, Evidence configuration, runtime requirements, validation, and connection checks. Database configuration is available in Database management.</p>
<p className="text-sm leading-6 text-muted-foreground">{t("Selecting a workspace from the left opens its own revision, Evidence configuration, runtime requirements, validation, and connection checks. Database configuration is available in Database management.")}</p>
</section>
) : (
<section className="mx-auto max-w-4xl space-y-5">
<div className="border-b border-border/70 pb-4">
<p className="thot-label">Selected workspace</p>
<p className="thot-label">{t("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 && <div role="tablist" aria-label="Workspace areas" className="thot-workspace-area-tabs">
{page && <div role="tablist" aria-label={t("Workspace areas")} className="thot-workspace-area-tabs">
{workspaceAreas.map((area, index) => <button
key={area}
ref={element => { areaTabs.current[index] = element; }}
@@ -563,105 +562,101 @@ export function WorkspaceManager({
setActiveArea(workspaceAreas[next]);
areaTabs.current[next]?.focus();
}}
>{area}</button>)}
>{t(area)}</button>)}
</div>}
{page && selectedId && <div {...areaPanel("Preparation")}>
<WorkspacePreparation workspaceId={selectedId} canManage={canPreprocess} />
</div>}
{(detailQuery.isLoading || runtimeQuery.isLoading) && <p className="text-sm text-muted-foreground">Loading workspace configuration…</p>}
{(detailQuery.isLoading || runtimeQuery.isLoading) && <p className="text-sm text-muted-foreground">{t("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()]); }} />
<QueryError name="Workspace configuration failed" message={t("Could not load this workspace configuration.")} retryLabel={t("Retry workspace configuration")} onRetry={() => { void Promise.all([detailQuery.refetch(), runtimeQuery.refetch()]); }} />
)}
{detailQuery.data && runtime && (
<>
<div {...areaPanel("About")}>
<div className="max-w-3xl">
<h3 className="font-heading text-lg font-semibold">About this workspace</h3>
<h3 className="font-heading text-lg font-semibold">{t("About this workspace")}</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
<span className="font-medium text-foreground">{selectedSummary.displayName}</span> is one workspace in the shared repository. Its workspace.yaml defines identity, language, and optional Evidence sources for this data environment. Its database is configured and described in the PostgreSQL Catalog.
</p>
<span className="font-medium text-foreground">{selectedSummary.displayName}</span> {t("is one workspace in the shared repository. Its workspace.yaml defines identity, language, and optional Evidence sources for this data environment. Its database is configured and described in the PostgreSQL Catalog.")}</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
The active revision shown below is the exact version currently activated by ThothII. Reading or checking it does not modify the repository. Source changes are made in Git and become available after a new revision is fetched, validated, and activated.
</p>
{t("The active revision shown below is the exact version currently activated by ThothII. Reading or checking it does not modify the repository. Source changes are made in Git and become available after a new revision is fetched, validated, and activated.")}</p>
</div>
<dl className="grid gap-3 rounded-lg border border-border bg-muted/20 p-4 text-sm sm:grid-cols-2">
<div><dt className="text-xs text-muted-foreground">Source file</dt><dd className="font-mono">{selectedSummary.file}</dd></div>
<div><dt className="text-xs text-muted-foreground">Active revision</dt><dd className="truncate font-mono">{detailQuery.data.revision.commit}</dd></div>
<div><dt className="text-xs text-muted-foreground">Workspace language</dt><dd className="font-mono text-xs">{detailQuery.data.workspace.workspace.language}</dd></div>
<div><dt className="text-xs text-muted-foreground">Runtime status</dt><dd>{stateLabel(runtime.configurationState)}</dd></div>
<div><dt className="text-xs text-muted-foreground">{t("Source file")}</dt><dd className="font-mono">{selectedSummary.file}</dd></div>
<div><dt className="text-xs text-muted-foreground">{t("Active revision")}</dt><dd className="truncate font-mono">{detailQuery.data.revision.commit}</dd></div>
<div><dt className="text-xs text-muted-foreground">{t("Workspace language")}</dt><dd className="font-mono text-xs">{detailQuery.data.workspace.workspace.language}</dd></div>
<div><dt className="text-xs text-muted-foreground">{t("Runtime status")}</dt><dd>{t(stateLabel(runtime.configurationState))}</dd></div>
</dl>
</div>
<div {...areaPanel("Specific actions")}>
<div>
<h3 className="font-heading text-lg font-semibold">Workspace-specific actions</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">The validation and connection checks below apply only to <span className="font-medium text-foreground">{selectedSummary.displayName}</span> and read its active revision without changing or publishing it.</p>
<h3 className="font-heading text-lg font-semibold">{t("Workspace-specific actions")}</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{t("The validation and connection checks below apply only to")} <span className="font-medium text-foreground">{selectedSummary.displayName}</span> {t("and read its active revision without changing or publishing it.")}</p>
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div data-testid="workspace-validation-card" className="rounded-lg border border-border p-4">
<h4 className="font-heading font-semibold">Validate workspace source</h4>
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">Confirms that workspace.yaml and the directories belonging to this workspace follow the supported format. This identifies source problems before activation, without changing repository files.</p>
<h4 className="font-heading font-semibold">{t("Validate workspace source")}</h4>
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">{t("Confirms that workspace.yaml and the directories belonging to this workspace follow the supported format. This identifies source problems before activation, without changing repository files.")}</p>
{validationNotice && (
<p role="status" className="mt-3 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700">
<CheckCircle2 className="mr-1 inline size-4 text-emerald-600" />{validationNotice}
<CheckCircle2 className="mr-1 inline size-4 text-emerald-600" />{t(validationNotice)}
</p>
)}
{validationDiagnostics.length > 0 && (
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
{validationDiagnostics.map((diagnostic) => (
<p key={diagnostic} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{diagnostic}
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{renderAdminDiagnostic(t, diagnostic)}
</p>
))}
</div>
)}
{canManageWorkspace && <Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "validate"} onClick={() => { void validateSource(); }}>
<ClipboardCheck />Validate workspace source
</Button>}
<ClipboardCheck />{t("Validate workspace source")}</Button>}
</div>
<div data-testid="workspace-connection-card" className="rounded-lg border border-border p-4">
<h4 className="font-heading font-semibold">Test workspace connections</h4>
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">Verifies the database configured in Database Management, plus this workspace&apos;s Evidence source and the installation semantic services. The result is informational.</p>
<h4 className="font-heading font-semibold">{t("Test workspace connections")}</h4>
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">{t("Verifies the database configured in Database Management, plus this workspace's Evidence source and the installation semantic services. The result is informational.")}</p>
{connectionNotice && (
<p role="status" className="mt-3 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700">
<CheckCircle2 className="mr-1 inline size-4 text-emerald-600" />{connectionNotice}
<CheckCircle2 className="mr-1 inline size-4 text-emerald-600" />{renderAdminDiagnostic(t, connectionNotice)}
</p>
)}
{connectionDiagnostics.length > 0 && (
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
{connectionDiagnostics.map((diagnostic) => (
<p key={diagnostic} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{diagnostic}
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{renderAdminDiagnostic(t, diagnostic)}
</p>
))}
</div>
)}
{canManageWorkspace && <Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "test"} onClick={() => { void testConnections(); }}>
<FlaskConical />Test workspace connections
</Button>}
<FlaskConical />{t("Test workspace connections")}</Button>}
</div>
</div>
</div>
<div {...areaPanel("Authentication")}>
{!authentication && <p className="text-sm text-muted-foreground">Run a connection test in Specific actions to inspect authentication diagnostics.</p>}
{!authentication && <p className="text-sm text-muted-foreground">{t("Run a connection test in Specific actions to inspect authentication diagnostics.")}</p>}
{authentication && <section data-testid="workspace-authentication" className="rounded-lg border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="font-heading font-semibold">Authentication</h4>
<h4 className="font-heading font-semibold">{t("Authentication")}</h4>
<span className={authentication.ready ? "text-sm font-medium text-emerald-700" : "text-sm font-medium text-amber-700"}>
{authentication.ready ? "Passed" : "Failed"}
{authentication.ready ? t("Passed") : t("Failed")}
</span>
</div>
{authentication.checks.some(({ level }) => level === "error") && <div role="alert" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
{authentication.checks.filter(({ level }) => level === "error").map(({ code, field, message }) => (
<p key={`${code}:${field ?? ""}`} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />
{code}: {field === undefined ? message : `${field} — ${message}`}
{code}: {field === undefined ? t(message) : `${field} — ${t(message)}`}
</p>
))}
</div>}
@@ -671,28 +666,28 @@ export function WorkspaceManager({
<div className="mb-4 flex items-start gap-3">
<KeyRound className="mt-0.5 size-5 text-primary" />
<div>
<h3 className="font-heading font-semibold">Runtime secrets</h3>
<p className="mt-1 text-sm leading-5 text-muted-foreground">Some connectors need credentials at runtime. ThothII stores these values separately from Git and never displays stored values. Enter a value only when adding or replacing a credential for this workspace.</p>
<h3 className="font-heading font-semibold">{t("Runtime secrets")}</h3>
<p className="mt-1 text-sm leading-5 text-muted-foreground">{t("Some connectors need credentials at runtime. ThothII stores these values separately from Git and never displays stored values. Enter a value only when adding or replacing a credential for this workspace.")}</p>
</div>
</div>
{runtime.requirements.length === 0 ? (
<p className="text-sm text-muted-foreground">This workspace does not require user-provided runtime secrets for its selected connectors.</p>
<p className="text-sm text-muted-foreground">{t("This workspace does not require user-provided runtime secrets for its selected connectors.")}</p>
) : (
<div className="grid gap-4">
{runtime.requirements.map((requirement) => (
<div key={requirement.id} className="grid gap-2 rounded-md bg-muted/30 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<label htmlFor={`workspace-secret-${requirement.id}`} className="font-medium">{requirement.label}</label>
<label htmlFor={`workspace-secret-${requirement.id}`} className="font-medium">{t(requirement.label)}</label>
<span className={requirement.configured ? "text-xs font-medium text-emerald-700" : "text-xs font-medium text-amber-700"}>
{requirement.configured ? "Configured" : "Not configured"}
{requirement.configured ? t("Configured") : t("Not configured")}
</span>
</div>
<p className="text-xs text-muted-foreground">{requirement.description}{requirement.required ? " Required for this workspace." : " Optional."}</p>
<p className="text-xs text-muted-foreground">{t(requirement.description)}{requirement.required ? t(" Required for this workspace.") : t(" Optional.")}</p>
{requirement.input === "textarea" ? (
<textarea
id={`workspace-secret-${requirement.id}`}
value={secretValues[requirement.id] ?? ""}
placeholder="Enter a new or replacement value"
placeholder={t("Enter a new or replacement value")}
autoComplete="off"
rows={4}
onChange={(event) => setSecretValues((current) => ({ ...current, [requirement.id]: event.target.value }))}
@@ -703,7 +698,7 @@ export function WorkspaceManager({
id={`workspace-secret-${requirement.id}`}
type="password"
value={secretValues[requirement.id] ?? ""}
placeholder="Enter a new or replacement value"
placeholder={t("Enter a new or replacement value")}
autoComplete="new-password"
onChange={(event) => setSecretValues((current) => ({ ...current, [requirement.id]: event.target.value }))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -715,27 +710,24 @@ export function WorkspaceManager({
size="sm"
variant="ghost"
disabled={busyAction === `forget:${requirement.id}`}
aria-label={`Forget stored ${requirement.label}`}
aria-label={t("Forget stored {label}", { label: t(requirement.label) })}
onClick={() => { void forgetSecret(requirement.id); }}
>
<Trash2 />Forget stored value
</Button>
<Trash2 />{t("Forget stored value")}</Button>
</div>
)}
</div>
))}
<div>
<Button disabled={!hasEnteredSecrets || busyAction === "save-secrets"} onClick={() => { void saveSecrets(); }}>
<KeyRound />Save entered secrets
</Button>
<KeyRound />{t("Save entered secrets")}</Button>
</div>
</div>
)}
</div>}
{!canManageSecrets && (
<p className="rounded-lg border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
Runtime secret controls are available only to authorized workspace operators.
</p>
{t("Runtime secret controls are available only to authorized workspace operators.")}</p>
)}
</div>
</>
@@ -1,4 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { setLocale } from "../i18n";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
@@ -6,6 +7,16 @@ import { server } from "../test/msw";
import type { WorkspacePreprocessingStatus } from "../api/workspace-preprocessing";
import { WorkspacePreprocessingControl } from "./WorkspacePreprocessingControl";
afterEach(() => act(() => setLocale("en")));
test("translates a live readiness diagnostic while preserving its catalog revision", () => {
renderControl({ schemaVersion: 1, workspaceId: "catalog-workspace", state: "ready",
actionable: true, clearable: true, detail: "Catalog revision 18 is indexed." });
act(() => setLocale("it"));
expect(screen.getByText("La revisione 18 del Catalogo è indicizzata.")).toBeVisible();
expect(screen.getByRole("button", { name: "Esegui di nuovo" })).toBeEnabled();
});
function renderControl(status: WorkspacePreprocessingStatus) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
@@ -1,3 +1,4 @@
import { getLocale, translate, useI18n } from "../i18n";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Check, CircleAlert, Clock3, LoaderCircle, ShieldCheck, Trash2 } from "lucide-react";
@@ -34,6 +35,20 @@ const INITIAL_PROGRESS: PreprocessingProgress = {
totalSteps: 4,
};
// Only deterministic service diagnostics are localized. Unknown diagnostic text
// falls back unchanged; revision numbers remain technical values.
function diagnosticMessage(message: string): string {
const revision = /^Catalog revision (\d+) is (not )?indexed\.$/.exec(message);
if (revision) return translate(revision[2]
? "Catalog revision {revision} is not indexed."
: "Catalog revision {revision} is indexed.", { revision: revision[1] });
const version = /^Database configuration v(\d+) is newer than the latest Catalog synchronization(?: v(\d+))?\.$/.exec(message);
if (version) return version[2]
? translate("Database configuration v{version} is newer than the latest Catalog synchronization v{synced}.", { version: version[1], synced: version[2] })
: translate("Database configuration v{version} is newer than the latest Catalog synchronization.", { version: version[1] });
return translate(message);
}
function stateClasses(state: WorkspacePreprocessingState | undefined): string {
switch (state) {
case "ready": return "text-emerald-700";
@@ -55,12 +70,13 @@ function StatusIcon({ state }: { state?: WorkspacePreprocessingState }) {
}
function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
const { t: translate } = useI18n();
const step = Math.max(1, Math.min(progress.step, progress.totalSteps));
return (
<div className="col-span-2 mt-1 grid min-w-0 max-w-full gap-1">
<div className="flex min-w-0 max-w-full items-center justify-between gap-[0.5rem] text-[9px] leading-none">
<span className="min-w-0 truncate font-medium text-foreground">
{PROGRESS_LABELS[progress.stage]}
{translate(PROGRESS_LABELS[progress.stage])}
</span>
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
{step} / {progress.totalSteps}
@@ -68,11 +84,11 @@ function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
</div>
<div
role="progressbar"
aria-label="Preprocessing progress"
aria-label={translate("Preprocessing progress")}
aria-valuemin={1}
aria-valuemax={progress.totalSteps}
aria-valuenow={step}
aria-valuetext={`${PROGRESS_LABELS[progress.stage]}, stage ${step} of ${progress.totalSteps}`}
aria-valuetext={translate("{stage}, stage {step} of {total}", { stage: translate(PROGRESS_LABELS[progress.stage]), step, total: progress.totalSteps })}
className="grid grid-cols-4 gap-1"
>
{Array.from({ length: progress.totalSteps }, (_, index) => (
@@ -96,14 +112,15 @@ function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
function formatFinishedAt(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "Unavailable";
return new Intl.DateTimeFormat(undefined, {
if (Number.isNaN(parsed.getTime())) return translate("Unavailable");
return new Intl.DateTimeFormat(getLocale(), {
dateStyle: "medium",
timeStyle: "short",
}).format(parsed);
}
function Diagnostic({ status }: { status: WorkspacePreprocessingStatus }) {
const { t: translate } = useI18n();
if (status.state !== "blocked" && status.state !== "failed") return null;
return (
<div
@@ -115,46 +132,44 @@ function Diagnostic({ status }: { status: WorkspacePreprocessingStatus }) {
].join(" ")}
>
<div className="grid gap-0.5">
<strong className="font-semibold text-foreground">Why this happened</strong>
<span className="text-muted-foreground">{status.reason}</span>
<strong className="font-semibold text-foreground">{translate("Why this happened")}</strong>
<span className="text-muted-foreground">{status.reason && diagnosticMessage(status.reason)}</span>
</div>
<div className="grid gap-0.5">
<strong className="font-semibold text-foreground">Next step</strong>
<span className="text-muted-foreground">{status.nextStep}</span>
<strong className="font-semibold text-foreground">{translate("Next step")}</strong>
<span className="text-muted-foreground">{status.nextStep && diagnosticMessage(status.nextStep)}</span>
</div>
{status.state === "failed" && status.lastFailure ? (
<details className="overflow-hidden rounded-md border border-border bg-card">
<summary className="cursor-pointer px-2 py-1.5 font-semibold text-foreground">
Last run details
</summary>
{translate("Last run details")}</summary>
<dl className="grid gap-1.5 border-t border-border px-2 py-2">
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-1.5">
<dt className="text-muted-foreground">Failed stage</dt>
<dt className="text-muted-foreground">{translate("Failed stage")}</dt>
<dd className="min-w-0 break-all font-mono font-medium text-foreground">
{status.lastFailure.stage}
</dd>
</div>
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-1.5">
<dt className="text-muted-foreground">Error code</dt>
<dt className="text-muted-foreground">{translate("Error code")}</dt>
<dd className="min-w-0 break-all font-mono font-medium text-foreground">
{status.lastFailure.errorCode}
</dd>
</div>
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-1.5">
<dt className="text-muted-foreground">Finished</dt>
<dt className="text-muted-foreground">{translate("Finished")}</dt>
<dd className="font-medium text-foreground">
{formatFinishedAt(status.lastFailure.finishedAt)}
</dd>
</div>
</dl>
<p className="border-t border-border px-2 py-1.5 text-muted-foreground">
Only the latest diagnostic is retained. Service log: <code>docker compose logs core</code>.
{translate("Only the latest diagnostic is retained. Service log: ")}<code>docker compose logs core</code>.
</p>
</details>
) : (
<p className="border-t border-border pt-2 text-muted-foreground">
No preprocessing log is available because a run did not start.
</p>
{translate("No preprocessing log is available because a run did not start.")}</p>
)}
</div>
);
@@ -175,6 +190,7 @@ export function WorkspacePreprocessingControl({
canManage: boolean;
presentation?: "rail" | "page";
}) {
const { t: translate } = useI18n();
const queryClient = useQueryClient();
const [confirmingClear, setConfirmingClear] = useState(false);
const mutation = useMutation({
@@ -196,7 +212,7 @@ export function WorkspacePreprocessingControl({
},
);
},
onError: (error) => toast.error(apiErrorMessage(error)),
onError: (error) => toast.error(translate(apiErrorMessage(error))),
onSettled: async () => {
if (!workspaceId) return;
await queryClient.invalidateQueries({ queryKey: ["workspace-preprocessing", workspaceId] });
@@ -209,9 +225,9 @@ export function WorkspacePreprocessingControl({
},
onSuccess: () => {
setConfirmingClear(false);
toast.success("Reference vectors and LSH cleared. Memory preserved.");
toast.success(translate("Reference vectors and LSH cleared. Memory preserved."));
},
onError: (error) => toast.error(apiErrorMessage(error)),
onError: (error) => toast.error(translate(apiErrorMessage(error))),
onSettled: async () => {
if (!workspaceId) return;
await queryClient.invalidateQueries({ queryKey: ["workspace-preprocessing", workspaceId] });
@@ -241,14 +257,13 @@ export function WorkspacePreprocessingControl({
return (
<section
aria-label="Workspace preprocessing"
aria-label={translate("Workspace preprocessing")}
aria-busy={state === "running" || loading || clearMutation.isPending}
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
</p>
{translate("Preprocessing")}</p>
{confirmingClear ? (
<div className="rounded-lg border border-destructive/25 bg-destructive/[0.045] p-2.5">
<div className="grid grid-cols-[1.5rem_minmax(0,1fr)] items-center gap-1.5">
@@ -256,10 +271,9 @@ export function WorkspacePreprocessingControl({
<AlertTriangle aria-hidden="true" className="size-3.5" />
</span>
<span className="grid gap-0.5">
<strong className="text-[10px] font-semibold text-foreground">Clear derived data?</strong>
<strong className="text-[10px] font-semibold text-foreground">{translate("Clear derived data?")}</strong>
<small className="text-[8px] leading-tight text-muted-foreground">
The workspace will require preprocessing.
</small>
{translate("The workspace will require preprocessing.")}</small>
</span>
</div>
<div className="my-2 grid gap-1.5 border-y border-destructive/15 py-2 text-[8px]">
@@ -267,22 +281,21 @@ export function WorkspacePreprocessingControl({
<span className="grid size-[18px] place-items-center rounded bg-destructive/10 text-destructive">
<Trash2 aria-hidden="true" className="size-3" />
</span>
<span><strong className="block text-foreground">Clear</strong><small className="text-muted-foreground">Schema/Evidence vectors, LSH and derived checkpoints</small></span>
<span><strong className="block text-foreground">{translate("Clear")}</strong><small className="text-muted-foreground">{translate("Schema/Evidence vectors, LSH and derived checkpoints")}</small></span>
</div>
<div className="grid grid-cols-[1.2rem_minmax(0,1fr)] items-center gap-1.5">
<span className="grid size-[18px] place-items-center rounded bg-emerald-600/10 text-emerald-700">
<ShieldCheck aria-hidden="true" className="size-3" />
</span>
<span><strong className="block text-foreground">Keep</strong><small className="text-muted-foreground">Memory and solved questions</small></span>
<span><strong className="block text-foreground">{translate("Keep")}</strong><small className="text-muted-foreground">{translate("Memory and solved questions")}</small></span>
</div>
</div>
<div className="grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
<Button type="button" variant="outline" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
Cancel
</Button>
{translate("Cancel")}</Button>
<Button type="button" variant="destructive" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => clearMutation.mutate()}>
{clearMutation.isPending ? <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <Trash2 aria-hidden="true" />}
{clearMutation.isPending ? "Clearing" : "Clear derived data"}
{clearMutation.isPending ? translate("Clearing") : translate("Clear derived data")}
</Button>
</div>
</div>
@@ -293,8 +306,8 @@ export function WorkspacePreprocessingControl({
<StatusIcon state={state} />
</span>
<div className="grid min-w-0 gap-0.5" role="status" aria-live="polite">
<strong className="text-[11px] font-semibold leading-tight text-foreground">{title}</strong>
{state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={detail}>{detail}</span>}
<strong className="text-[11px] font-semibold leading-tight text-foreground">{translate(title)}</strong>
{state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={diagnosticMessage(detail)}>{diagnosticMessage(detail)}</span>}
</div>
{progress && <PhaseProgress progress={progress} />}
<div className="col-span-2 mt-0.5 grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
@@ -304,22 +317,21 @@ export function WorkspacePreprocessingControl({
size="xs"
className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px] hover:border-destructive/35 hover:bg-destructive/5 hover:text-destructive"
disabled={!clearable}
title={!canManage ? "Database management permission is required" : undefined}
title={!canManage ? translate("Database management permission is required") : undefined}
onClick={() => setConfirmingClear(true)}
>
<Trash2 aria-hidden="true" /> Clear
</Button>
<Trash2 aria-hidden="true" /> {translate("Clear")}</Button>
<Button
type="button"
variant="outline"
size="xs"
className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px]"
disabled={!actionable}
title={!canManage ? "Database management permission is required" : undefined}
title={!canManage ? translate("Database management permission is required") : undefined}
onClick={() => mutation.mutate()}
>
{state === "running" && <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" />}
{state === "running" ? "Running" : actionLabel}
{state === "running" ? translate("Running") : translate(actionLabel)}
</Button>
</div>
</div>
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useI18n } from "../../i18n";
import { getWorkspacePreprocessingStatus } from "../../api/workspace-preprocessing";
import { WorkspacePreprocessingControl } from "../WorkspacePreprocessingControl";
import { AdministrationLink } from "./AdministrationNavigation";
@@ -6,19 +7,20 @@ import { AdministrationLink } from "./AdministrationNavigation";
export function WorkspacePreparation({ workspaceId, canManage }: {
workspaceId: string; canManage: boolean;
}) {
const { t } = useI18n();
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>;
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>
if (!canManage) return <p className="text-sm text-muted-foreground">{t("Database management permission is required to inspect or run preprocessing.")}</p>;
return <section aria-label={t("Workspace readiness")} className="thot-workspace-preparation">
<p className="thot-administration-eyebrow">{t("Preparation")}</p>
<h3 className="font-heading text-lg font-semibold mt-1">{t("Ready for analysis")}</h3>
<p className="mt-2 text-sm text-muted-foreground">{t("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">{t("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>
<div><dt className="text-muted-foreground">{t("Catalog revision")}</dt><dd className="font-mono">{query.data.metadataRevision ?? t("Unavailable")}</dd></div>
<div><dt className="text-muted-foreground">{t("Prepared revision")}</dt><dd className="font-mono">{query.data.preprocessedMetadataRevision ?? t("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>}
{query.isError && <button className="thot-administration-link" onClick={() => void query.refetch()}>{t("Retry readiness")}</button>}
</section>;
}
@@ -1,3 +1,4 @@
import { getLocale, translate, useI18n } from "../../i18n";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Check, CheckCircle2, Circle, LoaderCircle, Minus, RotateCcw, X } from "lucide-react";
@@ -83,7 +84,7 @@ function elapsed(run: CatalogSyncRun, now: number): string {
}
function stateLabel(run: CatalogSyncRun): string {
return run.state.replaceAll("_", " ");
return translate(run.state.replaceAll("_", " "));
}
export function CatalogSyncDrawer({
@@ -95,6 +96,7 @@ export function CatalogSyncDrawer({
onRunUpdate,
onCatalogChanged,
}: Props) {
const { t } = useI18n();
const queryClient = useQueryClient();
const [events, setEvents] = useState<CatalogSyncEvent[]>([]);
const [now, setNow] = useState(Date.now());
@@ -218,7 +220,7 @@ export function CatalogSyncDrawer({
|| (run && !terminal(run) ? currentPhase > confirmationPhase : furthestEventPhase > confirmationPhase);
const deletionCount = useMemo(() => run?.plannedDiff
? run.plannedDiff.deletedTables.length + run.plannedDiff.deletedColumns.length + run.plannedDiff.deletedRelationships.length
: 0, [run]);
: 0, [run, t]);
const perform = async (kind: "confirm" | "cancel" | "retry") => {
if (!run) return;
@@ -233,28 +235,26 @@ export function CatalogSyncDrawer({
queryClient.setQueryData(["catalog-sync-run", next.id], next);
await historyQuery.refetch();
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setAction(null);
}
};
const historySection = (
<section className="thot-catalog-drawer__history mt-5" aria-label="Synchronization history">
<h3 className="thot-label mb-2">Recent runs</h3>
{historyQuery.isLoading ? <p className="text-sm text-muted-foreground">Loading recent runs…</p> : null}
<section className="thot-catalog-drawer__history mt-5" aria-label={t("Synchronization history")}>
<h3 className="thot-label mb-2">{t("Recent runs")}</h3>
{historyQuery.isLoading ? <p className="text-sm text-muted-foreground">{t("Loading recent runs…")}</p> : null}
{historyQuery.isError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm" role="alert">
<p className="font-semibold text-destructive">Synchronization history could not be loaded.</p>
<p className="font-semibold text-destructive">{t("Synchronization history could not be loaded.")}</p>
<Button type="button" variant="outline" size="sm" className="mt-3" disabled={historyQuery.isFetching} onClick={() => void historyQuery.refetch()}>
<RotateCcw className={historyQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry history
</Button>
<RotateCcw className={historyQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Retry history")}</Button>
</div>
) : null}
{!historyQuery.isLoading && !historyQuery.isError && (historyQuery.data ?? []).length === 0 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-4 text-sm text-muted-foreground">
No synchronization runs yet.
</div>
{t("No synchronization runs yet.")}</div>
) : null}
{(historyQuery.data ?? []).length > 0 ? (
<div className="divide-y divide-border rounded-md border border-border">
@@ -267,8 +267,8 @@ export function CatalogSyncDrawer({
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 aria-[current=true]:bg-primary/8"
onClick={() => onRunChange(item)}
>
<span>{item.scope}</span>
<span className="text-xs text-muted-foreground">{stateLabel(item)} · {new Date(item.createdAt).toLocaleString()}</span>
<span>{t(item.scope)}</span>
<span className="text-xs text-muted-foreground">{t(stateLabel(item))} · {new Date(item.createdAt).toLocaleString(getLocale())}</span>
</button>
))}
</div>
@@ -281,43 +281,41 @@ export function CatalogSyncDrawer({
<>
{run.state === "awaiting_confirmation" ? (
<Button type="button" disabled={action !== null} onClick={() => void perform("confirm")}>
{action === "confirm" ? <LoaderCircle className="animate-spin" /> : <Check />} Confirm removals
</Button>
{action === "confirm" ? <LoaderCircle className="animate-spin" /> : <Check />} {t("Confirm removals")}</Button>
) : null}
{["queued", "running", "awaiting_confirmation"].includes(run.state) ? (
<Button type="button" variant="outline" disabled={action !== null} onClick={() => void perform("cancel")}>Cancel</Button>
<Button type="button" variant="outline" disabled={action !== null} onClick={() => void perform("cancel")}>{t("Cancel")}</Button>
) : null}
{["failed", "cancelled", "interrupted"].includes(run.state) ? (
<Button type="button" disabled={action !== null} onClick={() => void perform("retry")}>
<RotateCcw className={action === "retry" ? "animate-spin" : ""} /> Retry
</Button>
<RotateCcw className={action === "retry" ? "animate-spin" : ""} /> {t("Retry")}</Button>
) : null}
</>
) : undefined;
return (
<FleetLedgerDrawer
open
ariaLabel="Schema synchronization"
eyebrow="Schema synchronization"
ariaLabel={t("Schema synchronization")}
eyebrow={t("Schema synchronization")}
title={run ? (
<span className="inline-flex items-center gap-2">
{!terminal(run) ? <LoaderCircle className="size-5 animate-spin" aria-hidden="true" /> : null}
{stateLabel(run)[0].toUpperCase()}{stateLabel(run).slice(1)}
{t(stateLabel(run)[0].toUpperCase())}{t(stateLabel(run).slice(1))}
</span>
) : "Synchronization history"}
description={run ? `${run.scope} · ${elapsed(run, now)}` : undefined}
) : t("Synchronization history")}
description={run ? `${t(run.scope.replaceAll("_", " "))} · ${elapsed(run, now)}` : undefined}
onClose={onClose}
closeLabel="Close synchronization drawer"
closeLabel={t("Close synchronization drawer")}
busy={action !== null}
bodyClassName="thot-catalog-drawer__history-layout"
footer={footer}
>
{runQuery.isLoading ? <p className="text-sm text-muted-foreground">Loading synchronization…</p> : null}
{runQuery.isLoading ? <p className="text-sm text-muted-foreground">{t("Loading synchronization…")}</p> : null}
{run ? (
<>
<section aria-labelledby="synchronization-steps-heading">
<h3 id="synchronization-steps-heading" className="thot-label mb-3">Synchronization steps</h3>
<ol className="grid grid-cols-2 gap-x-4 gap-y-2.5 sm:grid-cols-3" aria-label="Synchronization steps">
<h3 id="synchronization-steps-heading" className="thot-label mb-3">{t("Synchronization steps")}</h3>
<ol className="grid grid-cols-2 gap-x-4 gap-y-2.5 sm:grid-cols-3" aria-label={t("Synchronization steps")}>
{phases.map((phase, index) => {
const confirmationUnknown = phase === "awaiting_confirmation"
&& passedConfirmation
@@ -340,9 +338,9 @@ export function CatalogSyncDrawer({
const current = index === currentPhase && !terminal(run);
const label = phase === "awaiting_confirmation"
? confirmationSkipped
? "Confirmation not required"
? t("Confirmation not required")
: confirmationUnknown
? "Checking confirmation"
? t("Checking confirmation")
: confirmationWasReceived
? phaseLabels[phase].completed
: current
@@ -360,17 +358,17 @@ export function CatalogSyncDrawer({
const status = terminalOutcome
? run.state
: confirmationSkipped
? "not required"
? t("not required")
: complete
? "completed"
: current
? "in progress"
? t("in progress")
: "pending";
return (
<li
key={phase}
aria-current={current ? "step" : undefined}
aria-label={`${label}, ${status}`}
aria-label={`${t(label)}, ${t(status)}`}
className={`flex items-center gap-2 text-xs ${failedOutcome ? "font-semibold text-destructive" : interruptedOutcome ? "font-semibold text-amber-600" : terminalOutcome || current ? "font-semibold text-foreground" : "text-muted-foreground"}`}
>
{confirmationSkipped ? (
@@ -388,7 +386,7 @@ export function CatalogSyncDrawer({
) : (
<Circle aria-hidden="true" className="size-3.5" />
)}
{label}
{t(label)}
</li>
);
})}
@@ -405,40 +403,39 @@ export function CatalogSyncDrawer({
</div>
{run.state === "awaiting_confirmation" && run.plannedDiff ? (
<section className="mt-5 rounded-md border border-amber-500/50 bg-amber-500/8 p-4" aria-label="Destructive changes">
<section className="mt-5 rounded-md border border-amber-500/50 bg-amber-500/8 p-4" aria-label={t("Destructive changes")}>
<div className="flex gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600" />
<div>
<h3 className="font-semibold">Confirm {deletionCount} removals</h3>
<p className="mt-1 text-sm text-muted-foreground">The database will be scanned again before anything is removed.</p>
<p className="mt-1 text-sm text-muted-foreground">Memory cards that depend on removed tables or columns will also be deleted, including their links and search entries.</p>
<h3 className="font-semibold">{t("Confirm")} {deletionCount} {t("removals")}</h3>
<p className="mt-1 text-sm text-muted-foreground">{t("The database will be scanned again before anything is removed.")}</p>
<p className="mt-1 text-sm text-muted-foreground">{t("Memory cards that depend on removed tables or columns will also be deleted, including their links and search entries.")}</p>
</div>
</div>
<ul className="mt-3 max-h-40 space-y-1 overflow-y-auto font-mono text-xs">
{run.plannedDiff.deletedTables.map((name) => <li key={`t-${name}`}>table · {name}</li>)}
{run.plannedDiff.deletedColumns.map((item) => <li key={`c-${item.tableName}-${item.columnName}`}>column · {item.tableName}.{item.columnName}</li>)}
{run.plannedDiff.deletedRelationships.map((item) => <li key={`r-${item.sourceTableName}-${item.constraintName}`}>relationship · {item.sourceTableName}.{item.constraintName}</li>)}
{run.plannedDiff.deletedTables.map((name) => <li key={`t-${name}`}>{t("table ·")} {name}</li>)}
{run.plannedDiff.deletedColumns.map((item) => <li key={`c-${item.tableName}-${item.columnName}`}>{t("column ·")} {item.tableName}.{item.columnName}</li>)}
{run.plannedDiff.deletedRelationships.map((item) => <li key={`r-${item.sourceTableName}-${item.constraintName}`}>{t("relationship ·")} {item.sourceTableName}.{item.constraintName}</li>)}
</ul>
</section>
) : null}
{run.counts.memoryDeleted !== undefined && <p className="mt-4 text-sm text-muted-foreground">
Dependent Memory cards removed: {run.counts.memoryDeleted}
{t("Dependent Memory cards removed:")} {run.counts.memoryDeleted}
</p>}
{run.errorMessage ? (
<div className="mt-5 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{run.errorMessage}
{t(run.errorMessage)}
</div>
) : null}
<section className="mt-5" aria-label="Synchronization log">
<section className="mt-5" aria-label={t("Synchronization log")}>
<div className="mb-2 flex items-center justify-between">
<h3 className="thot-label">Live log</h3>
<h3 className="thot-label">{t("Live log")}</h3>
<div className="flex items-center gap-2">
{!followingEventTail && events.length > 0 ? (
<Button type="button" variant="outline" size="xs" onClick={jumpToLatestEvent}>
Jump to latest
</Button>
{t("Jump to latest")}</Button>
) : null}
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
</div>
@@ -446,15 +443,15 @@ export function CatalogSyncDrawer({
<div
ref={eventLogRef}
role="log"
aria-label="Synchronization events"
aria-label={t("Synchronization events")}
aria-relevant="additions"
tabIndex={0}
onScroll={handleEventLogScroll}
className="thot-catalog-drawer__event-log max-h-64 overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
{events.length === 0 ? <p className="text-zinc-500">Waiting for events…</p> : events.map((event) => (
{events.length === 0 ? <p className="text-zinc-500">{t("Waiting for events…")}</p> : events.map((event) => (
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString()}</span>{event.message}
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString(getLocale())}</span>{t(event.message)}
</p>
))}
</div>
@@ -465,12 +462,11 @@ export function CatalogSyncDrawer({
) : (
<>
<div className="rounded-lg border border-dashed border-primary/30 bg-primary/5 px-5 py-6 text-sm">
<p className="font-semibold">{runQuery.isError ? "This synchronization run could not be loaded." : historyQuery.isLoading ? "Loading synchronization history…" : "No run selected."}</p>
<p className="mt-1 text-muted-foreground">Choose a recent run to inspect its steps and event log.</p>
<p className="font-semibold">{runQuery.isError ? t("This synchronization run could not be loaded.") : historyQuery.isLoading ? t("Loading synchronization history…") : t("No run selected.")}</p>
<p className="mt-1 text-muted-foreground">{t("Choose a recent run to inspect its steps and event log.")}</p>
{runQuery.isError ? (
<Button type="button" variant="outline" size="sm" className="mt-3" disabled={runQuery.isFetching} onClick={() => void runQuery.refetch()}>
<RotateCcw className={runQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry run
</Button>
<RotateCcw className={runQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Retry run")}</Button>
) : null}
</div>
{historySection}
@@ -1,3 +1,6 @@
import { useAdminGridLocale } from "./useAdminGridLocale";
import { adminMessage, type AdminMessage } from "./messages";
import { useI18n } from "../../i18n";
import { useEffect, useMemo, useRef, useState } from "react";
import { Menu } from "@base-ui/react/menu";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -35,7 +38,7 @@ interface Props {
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
onCatalogSyncRunStarted?: (run: CatalogSyncRun) => void;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onSuggestSensitive: (selection: SensitivityAnalysisRequest, scopeLabel: string) => Promise<void>;
onSuggestSensitive: (selection: SensitivityAnalysisRequest, scopeLabel: AdminMessage) => Promise<void>;
catalogOperationActive?: boolean;
onCatalogMetricsChanged?: () => void | Promise<void>;
presentation?: "legacy" | "fleet";
@@ -50,6 +53,7 @@ interface GridContext {
}
function KeyCell({ data }: ICellRendererParams<CatalogColumn>) {
const { t } = useI18n();
if (!data) return null;
return (
<div className="flex h-full items-center gap-1.5">
@@ -60,11 +64,12 @@ function KeyCell({ data }: ICellRendererParams<CatalogColumn>) {
}
function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknown, GridContext>) {
const { t } = useI18n();
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Edit metadata" : undefined}>
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={`Edit metadata for ${data.name}`} title={context.presentation === "legacy" ? `Edit metadata for ${data.name}` : undefined} onClick={(event) => context.onEdit(data, event.currentTarget)}>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? t("Edit metadata") : undefined}>
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={t("Edit metadata for {name}", { name: data.name })} title={context.presentation === "legacy" ? t("Edit metadata for {name}", { name: data.name }) : undefined} onClick={(event) => context.onEdit(data, event.currentTarget)}>
<Pencil aria-hidden="true" />
</Button>
</span>
@@ -73,6 +78,7 @@ function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknow
}
function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unknown, GridContext>) {
const { t } = useI18n();
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-center" onClick={(event) => event.stopPropagation()}>
@@ -81,8 +87,8 @@ function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unk
className="size-4 accent-primary outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
checked={data.sensitive}
disabled={!context.canManage || context.busy}
aria-label={`Sensitive data for ${data.name}`}
title="Mark this column as sensitive; this does not select it for actions."
aria-label={t("Sensitive data for {name}", { name: data.name })}
title={t("Mark this column as sensitive; this does not select it for actions.")}
onChange={(event) => context.onSensitiveChange(data, event.target.checked)}
onClick={(event) => event.stopPropagation()}
/>
@@ -107,6 +113,7 @@ function ColumnMetadataFieldMatrix({
onDescriptionChange: (value: string) => void;
onGeneratedDescriptionChange: (value: string) => void;
}) {
const { t } = useI18n();
const fieldClass = [
"thot-column-metadata-field flex min-h-0 flex-col gap-1.5",
"text-sm font-semibold",
@@ -122,16 +129,14 @@ function ColumnMetadataFieldMatrix({
return (
<div
role="group"
aria-label="Column metadata fields"
aria-label={t("Column metadata fields")}
className="thot-column-metadata-matrix mt-5 grid min-h-0 gap-4 sm:grid-cols-2"
>
<label className={fieldClass}>
Source comment
<textarea className={readOnlyClass} value={column.sourceComment ?? ""} readOnly />
{t("Source comment")}<textarea className={readOnlyClass} value={column.sourceComment ?? ""} readOnly />
</label>
<label className={fieldClass}>
Description
<textarea
{t("Description")}<textarea
className={editableClass}
value={description}
disabled={!canManage || busy}
@@ -139,8 +144,7 @@ function ColumnMetadataFieldMatrix({
/>
</label>
<label className={fieldClass}>
Generated description
<textarea
{t("Generated description")}<textarea
className={editableClass}
value={generatedDescription}
disabled={!canManage || busy}
@@ -148,11 +152,10 @@ function ColumnMetadataFieldMatrix({
/>
</label>
<label className={fieldClass}>
Sensitive motivation
<textarea
{t("Sensitive motivation")}<textarea
className={readOnlyClass}
value={column.sensitivityReason ?? ""}
placeholder="No sensitivity motivation recorded."
placeholder={t("No sensitivity motivation recorded.")}
readOnly
/>
</label>
@@ -175,6 +178,8 @@ export function DatabaseColumns({
onCatalogMetricsChanged,
presentation = "legacy",
}: Props) {
const { t } = useI18n();
const { locale, gridLocaleProps } = useAdminGridLocale();
const queryClient = useQueryClient();
const queryKey = ["catalog-columns", databaseId, table.id] as const;
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
@@ -208,7 +213,7 @@ export function DatabaseColumns({
Object.hasOwn(sensitiveDrafts, column.id)
? { ...column, sensitive: sensitiveDrafts[column.id]! }
: column
)), [data, sensitiveDrafts]);
)), [data, sensitiveDrafts, t]);
useEffect(() => { onNavigationStateChange({ dirty, busy }); }, [busy, dirty, onNavigationStateChange]);
useEffect(() => {
@@ -229,7 +234,7 @@ export function DatabaseColumns({
};
const closeEditor = () => {
if (busy) return;
if (editorDirty && !window.confirm("Discard unsaved column metadata?")) return;
if (editorDirty && !window.confirm(t("Discard unsaved column metadata?"))) return;
setEditingId(null);
window.setTimeout(() => originRef.current?.focus(), 0);
};
@@ -248,13 +253,13 @@ export function DatabaseColumns({
setVersion(updated.version);
setStale(false);
void onCatalogMetricsChanged?.();
toast.success("Column metadata saved");
toast.success(t("Column metadata saved"));
} catch (error) {
if (error instanceof ApiError && error.code === "column_stale") {
await refetch();
setStale(true);
setStaleBannerOpen(true);
} else toast.error(apiErrorMessage(error));
} else toast.error(t(apiErrorMessage(error)));
} finally { setBusy(false); }
};
const reloadColumn = async () => {
@@ -270,7 +275,7 @@ export function DatabaseColumns({
setVersion(latest.version);
setStale(false);
setStaleBannerOpen(true);
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
} catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(false); }
};
const consolidateDescriptions = async () => {
setBusy(true);
@@ -280,9 +285,9 @@ export function DatabaseColumns({
gridRef.current?.api.deselectAll();
setSelectedIds([]);
void onCatalogMetricsChanged?.();
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
toast.success((counts.copied === 1 ? t("Copied {copied} description; skipped {skipped}", { copied: counts.copied, skipped: counts.skipped }) : t("Copied {copied} descriptions; skipped {skipped}", { copied: counts.copied, skipped: counts.skipped })));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally { setBusy(false); }
};
const generateDescriptions = async () => {
@@ -296,9 +301,9 @@ export function DatabaseColumns({
selectedIds,
);
onDescriptionGenerationRunStarted(run);
toast.success(`Description generation started for ${selectedIds.length} column${selectedIds.length === 1 ? "" : "s"}`);
toast.success((selectedIds.length === 1 ? t("Description generation started for {count} column", { count: selectedIds.length }) : t("Description generation started for {count} columns", { count: selectedIds.length })));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally { setBusy(false); }
};
@@ -320,7 +325,7 @@ export function DatabaseColumns({
try {
await onSuggestSensitive(
{ scope: "selected_columns", targetIds: selectedIds },
`${selectedIds.length} selected column${selectedIds.length === 1 ? "" : "s"} in ${table.name}`,
(selectedIds.length === 1 ? adminMessage("{count} selected column in {name}", { count: selectedIds.length, name: table.name }) : adminMessage("{count} selected columns in {name}", { count: selectedIds.length, name: table.name })),
);
} catch {
// The page-level request reports the safe, specific reason and preserves the selection.
@@ -363,9 +368,9 @@ export function DatabaseColumns({
void onCatalogMetricsChanged?.();
}
if (failed.length > 0) {
toast.error(`${failed.length} sensitive flag${failed.length === 1 ? "" : "s"} could not be saved: ${apiErrorMessage(failed[0])}`);
toast.error((failed.length === 1 ? t("{count} sensitive flag could not be saved: {error}", { count: failed.length, error: apiErrorMessage(failed[0]) }) : t("{count} sensitive flags could not be saved: {error}", { count: failed.length, error: apiErrorMessage(failed[0]) })));
} else {
toast.success("Sensitive fields saved");
toast.success(t("Sensitive fields saved"));
}
} finally {
setSensitiveAction(null);
@@ -382,69 +387,69 @@ export function DatabaseColumns({
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
{
id: "sync-columns",
label: "Synchronize columns for this table",
group: "Synchronization",
scopeLabel: "Selected table",
runLabel: "Synchronize",
label: t("Synchronize columns for this table"),
group: t("Synchronization"),
scopeLabel: t("Selected table"),
runLabel: t("Synchronize"),
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
? t("You do not have permission to synchronize the catalog.")
: selectedIds.length === 0
? "Select at least one column."
? t("Select at least one column.")
: catalogOperationActive
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy
? "Another action is running."
? t("Another action is running.")
: undefined,
},
{
id: "generate-descriptions",
label: "Generate column descriptions",
group: "Descriptions",
runLabel: "Generate",
label: t("Generate column descriptions"),
group: t("Descriptions"),
runLabel: t("Generate"),
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to generate descriptions."
? t("You do not have permission to generate descriptions.")
: selectedIds.length === 0
? "Select at least one column."
? t("Select at least one column.")
: !selectedMetadataModel
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy
? "Another action is running."
? t("Another action is running.")
: undefined,
},
{
id: "consolidate-descriptions",
label: "Copy generated descriptions to Description",
group: "Descriptions",
runLabel: "Move",
label: t("Copy generated descriptions to Description"),
group: t("Descriptions"),
runLabel: t("Move"),
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to update metadata."
? t("You do not have permission to update metadata.")
: selectedIds.length === 0
? "Select at least one column."
? t("Select at least one column.")
: catalogOperationActive
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy
? "Another action is running."
? t("Another action is running.")
: undefined,
},
{
id: "suggest-sensitive",
label: "Analyze sensitive fields",
group: "Sensitive data",
runLabel: "Analyze",
label: t("Analyze sensitive fields"),
group: t("Sensitive data"),
runLabel: t("Analyze"),
disabled: !canManage || selectedIds.length === 0 || descriptionGenerationActive || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to review sensitive data."
? t("You do not have permission to review sensitive data.")
: selectedIds.length === 0
? "Select at least one column."
? t("Select at least one column.")
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy
? "Another action is running."
? t("Another action is running.")
: undefined,
},
];
@@ -469,9 +474,9 @@ export function DatabaseColumns({
onCatalogSyncRunStarted?.(run);
gridRef.current?.api.deselectAll();
setSelectedIds([]);
toast.success("Column synchronization started");
toast.success(t("Column synchronization started"));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setBusy(false);
}
@@ -479,31 +484,30 @@ export function DatabaseColumns({
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
{ field: "sensitive", headerName: "Sensitive", headerTooltip: "Mark this column as sensitive; this does not select it for actions.", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
{ field: "name", headerName: "Name", minWidth: 190, flex: 1, cellClass: "font-mono text-xs" },
{ field: "dataType", headerName: "Type", minWidth: 150, flex: 0.8, cellClass: "font-mono text-xs" },
{ headerName: "Keys", minWidth: 125, width: 125, sortable: false, filter: false, cellRenderer: KeyCell },
{ headerName: "Nullable", minWidth: 100, width: 100, valueGetter: ({ data: row }) => row ? (row.isNullable ? "Yes" : "No") : "" },
{ field: "sourceComment", headerName: "Source comment", minWidth: 220, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "generatedDescription", headerName: "Generated description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "description", headerName: "Description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: "Actions", width: 64, maxWidth: 64, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: ActionCell },
], []);
{ field: "sensitive", headerName: t("Sensitive"), headerTooltip: t("Mark this column as sensitive; this does not select it for actions."), minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
{ field: "name", headerName: t("Name"), minWidth: 190, flex: 1, cellClass: "font-mono text-xs" },
{ field: "dataType", headerName: t("Type"), minWidth: 150, flex: 0.8, cellClass: "font-mono text-xs" },
{ headerName: t("Keys"), minWidth: 125, width: 125, sortable: false, filter: false, cellRenderer: KeyCell },
{ headerName: t("Nullable"), minWidth: 100, width: 100, valueGetter: ({ data: row }) => row ? (row.isNullable ? t("Yes") : t("No")) : "" },
{ field: "sourceComment", headerName: t("Source comment"), minWidth: 220, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "generatedDescription", headerName: t("Generated description"), minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "description", headerName: t("Description"), minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: t("Actions"), width: 64, maxWidth: 64, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: ActionCell },
], [t]);
const context = useMemo<GridContext>(() => ({
canManage,
busy: busy || catalogOperationActive,
presentation,
onEdit: edit,
onSensitiveChange: changeSensitive,
}), [busy, canManage, catalogOperationActive, data, presentation]);
}), [busy, canManage, catalogOperationActive, data, presentation, t]);
const fleetQueryError = presentation === "fleet" && isError ? (
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
<p className="font-semibold text-destructive">Catalog columns could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<p className="font-semibold text-destructive">{t("Catalog columns could not be loaded.")}</p>
<p className="mt-1 leading-5 text-muted-foreground">{t(apiErrorMessage(error))}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry loading columns
</Button>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Retry loading columns")}</Button>
</div>
</div>
) : null;
@@ -511,13 +515,13 @@ export function DatabaseColumns({
if (editingId && active && presentation === "legacy") {
return (
<div className="mx-auto w-full max-w-4xl px-4 py-6 sm:px-5">
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={closeEditor}>← Back to columns</Button>
<p className="thot-label">{table.name} · column</p>
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={closeEditor}>{t("← Back to columns")}</Button>
<p className="thot-label">{table.name} {t("· column")}</p>
<h3 className="mt-1 font-heading text-2xl font-semibold">{active.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">Physical schema fields are read-only. Review fields can be edited.</p>
<p className="mt-1 text-sm text-muted-foreground">{t("Physical schema fields are read-only. Review fields can be edited.")}</p>
{stale ? (
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">{t("Newer column metadata is available.")}</p><p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload.")}</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this metadata can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button></div>
) : null}
<ColumnMetadataFieldMatrix
column={active}
@@ -529,8 +533,8 @@ export function DatabaseColumns({
onGeneratedDescriptionChange={setGeneratedDescription}
/>
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save />{busy ? "Saving…" : "Save metadata"}</Button>
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>{t("Cancel")}</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save />{busy ? t("Saving…") : t("Save metadata")}</Button>
</div>
</div>
);
@@ -546,31 +550,31 @@ export function DatabaseColumns({
actions={fleetActions}
selectedCount={selectedIds.length}
busy={busy}
busyLabel={sensitiveAction === "suggest" ? "Suggesting…" : sensitiveAction === "save" ? "Saving…" : "Running…"}
busyLabel={sensitiveAction === "suggest" ? t("Suggesting…") : sensitiveAction === "save" ? t("Saving…") : t("Running…")}
onRun={runFleetAction}
onClear={clearSelection}
label="Column action"
label={t("Column action")}
/>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? t("Saving…") : t("Save sensitive fields")}</Button> : null}
</>
) : (
<>
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
<span className="text-sm font-semibold">{selectedIds.length} {t("selected")}</span>
<Menu.Root>
<Menu.Trigger className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Trigger className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy}>{t("Actions")} <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedIds.length || catalogOperationActive || busy} onClick={() => void synchronizeColumns()}>Synchronize columns for this table</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "column description" : "column descriptions"}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>Copy generated descriptions to Description</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedIds.length || catalogOperationActive || busy} onClick={() => void synchronizeColumns()}>{t("Synchronize columns for this table")}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>{t("Generate")} {selectedIds.length === 1 ? t("column description") : t("column descriptions")}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>{t("Copy generated descriptions to Description")}</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{sensitiveAction === "suggest" ? "Analyzing…" : "Analyze sensitive fields"}</Button>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<Button type="button" variant="ghost" onClick={clearSelection}><X />Clear</Button>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy} title={descriptionGenerationActive ? t("Wait for the active description generation to finish") : undefined} onClick={() => void suggestSensitive()}><Sparkles />{sensitiveAction === "suggest" ? t("Analyzing…") : t("Analyze sensitive fields")}</Button>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? t("Saving…") : t("Save sensitive fields")}</Button> : null}
<Button type="button" variant="ghost" onClick={clearSelection}><X />{t("Clear")}</Button>
</>
)
) : (
@@ -580,30 +584,30 @@ export function DatabaseColumns({
actions={fleetActions}
selectedCount={0}
busy={busy}
busyLabel={sensitiveAction === "save" ? "Saving…" : "Running…"}
busyLabel={sensitiveAction === "save" ? t("Saving…") : t("Running…")}
onRun={runFleetAction}
onClear={clearSelection}
label="Column action"
label={t("Column action")}
/>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? t("Saving…") : t("Save sensitive fields")}</Button> : null}
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label={t("Search columns")} placeholder={t("Search")} value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />{t("Refresh")}</Button>
</>
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog columns</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="thot-label whitespace-nowrap">{t("Catalog columns")}</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label={t("Search columns")} placeholder={t("Search")} value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? t("Saving…") : t("Save sensitive fields")}</Button> : null}
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />{t("Refresh")}</Button>
</>
)
)}
</div>
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogColumn>
<AgGridReact<CatalogColumn> key={locale} {...gridLocaleProps}
ref={gridRef}
rowData={displayedColumns}
columnDefs={columns}
@@ -614,8 +618,8 @@ export function DatabaseColumns({
getRowId={({ data: row }) => row.id}
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
selectionColumnDef={{
headerName: "Select",
headerTooltip: "Select columns for actions",
headerName: t("Select"),
headerTooltip: t("Select columns for actions"),
width: 72,
minWidth: 72,
maxWidth: 72,
@@ -628,32 +632,32 @@ export function DatabaseColumns({
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No columns synchronized for this table."
overlayNoRowsTemplate={t("No columns synchronized for this table.")}
/>
</div>
</div>}
{presentation === "fleet" && active ? (
<FleetLedgerDrawer
open
eyebrow="Catalog metadata"
title="Review column description"
description="Physical schema fields are read-only. Review fields can be edited."
eyebrow={t("Catalog metadata")}
title={t("Review column description")}
description={t("Physical schema fields are read-only. Review fields can be edited.")}
onClose={closeEditor}
closeLabel="Close column metadata"
closeLabel={t("Close column metadata")}
busy={busy}
bodyClassName="thot-column-metadata-drawer-body"
footer={(
<>
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save aria-hidden="true" />{busy ? "Saving…" : "Save metadata"}</Button>
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>{t("Cancel")}</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save aria-hidden="true" />{busy ? t("Saving…") : t("Save metadata")}</Button>
</>
)}
>
<p className="thot-label">{table.name} · column</p>
<p className="thot-label">{table.name} {t("· column")}</p>
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{active.name}</h3>
{stale ? (
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">{t("Newer column metadata is available.")}</p><p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload.")}</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this metadata can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button></div>
) : null}
<ColumnMetadataFieldMatrix
column={active}
@@ -1,3 +1,4 @@
import { getLocale, useI18n, translate } from "../../i18n";
import type { RefObject, ReactNode } from "react";
import {
ArrowLeft,
@@ -34,11 +35,12 @@ interface FieldProps {
}
function Field({ label, hint, children }: FieldProps) {
const { t } = useI18n();
return (
<label className={labelClass}>
<span>{label}</span>
<span>{t(label)}</span>
{children}
{hint ? <span className="font-normal leading-4 text-muted-foreground">{hint}</span> : null}
{hint ? <span className="font-normal leading-4 text-muted-foreground">{t(hint)}</span> : null}
</label>
);
}
@@ -64,14 +66,15 @@ function SecretField({
onChange,
multiline = false,
}: SecretFieldProps) {
const { t } = useI18n();
if (readOnly) {
return (
<Field label={label} hint="Secret values are never displayed.">
<Field label={t(label)} hint={t("Secret values are never displayed.")}>
<input
className={inputClass}
value={row.secrets[name] ? "Configured" : "Not configured"}
readOnly
aria-label={label}
aria-label={t(label)}
/>
</Field>
);
@@ -79,29 +82,29 @@ function SecretField({
const permissionHint = canManageSecrets
? undefined
: "You do not have permission to replace secret values.";
: t("You do not have permission to replace secret values.");
return (
<Field label={label} hint={permissionHint}>
<Field label={t(label)} hint={permissionHint}>
{multiline ? (
<textarea
className={`${inputClass} min-h-24 resize-y py-2 font-mono text-xs leading-5`}
aria-label={label}
aria-label={t(label)}
autoComplete="off"
value={draft.secrets[name] ?? ""}
disabled={!canManageSecrets}
placeholder={row.secrets[name] ? "Configured; leave blank to keep" : "Paste value"}
placeholder={row.secrets[name] ? t("Configured; leave blank to keep") : t("Paste value")}
onChange={(event) => onChange(name, event.target.value)}
/>
) : (
<input
className={inputClass}
type="password"
aria-label={label}
aria-label={t(label)}
autoComplete="new-password"
value={draft.secrets[name] ?? ""}
disabled={!canManageSecrets}
placeholder={row.secrets[name] ? "Configured; leave blank to keep" : "Not configured"}
placeholder={row.secrets[name] ? t("Configured; leave blank to keep") : t("Not configured")}
onChange={(event) => onChange(name, event.target.value)}
/>
)}
@@ -146,10 +149,10 @@ export function databaseFormTitle(
mode: DatabaseFormMode,
presentation: "page" | "drawer" = "page",
): string {
if (mode === "configure") return "Configure catalog";
if (mode === "edit") return "Edit database";
if (mode === "delete") return presentation === "drawer" ? "Remove configuration" : "Delete database";
return "Database details";
if (mode === "configure") return translate("Configure catalog");
if (mode === "edit") return translate("Edit database");
if (mode === "delete") return presentation === "drawer" ? translate("Remove configuration") : translate("Delete database");
return translate("Database details");
}
export function DatabaseForm({
@@ -184,6 +187,7 @@ export function DatabaseForm({
activeSyncRun,
presentation = "page",
}: DatabaseFormProps) {
const { t } = useI18n();
const title = databaseFormTitle(mode, presentation);
const readOnly = mode === "view" || mode === "delete";
const editable = mode === "configure" || mode === "edit";
@@ -208,83 +212,77 @@ export function DatabaseForm({
>
{mode === "delete" ? (
<div className="rounded-md border border-destructive/35 bg-destructive/8 p-4 text-sm text-foreground" role="alert">
<p className="font-semibold text-destructive">This removes the local database configuration.</p>
<p className="font-semibold text-destructive">{t("This removes the local database configuration.")}</p>
<p className="mt-1 leading-5 text-muted-foreground">
{row.workspaceAvailable
? "The repository workspace remains available and will return to the list as Not configured. "
: "This orphaned catalog row will disappear from the list. "}
Secret references associated with this configuration are removed too. The source database is not modified.
</p>
? t("The repository workspace remains available and will return to the list as Not configured. ")
: t("This orphaned catalog row will disappear from the list. ")}
{t("Secret references associated with this configuration are removed too. The source database is not modified.")}</p>
</div>
) : null}
{stale ? (
staleBannerOpen ? (
<div className="rounded-md border border-amber-300/70 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="alert">
<p className="font-semibold">A newer database configuration is available.</p>
<p className="mt-1 leading-5">Reload the latest values before saving, testing, or deleting. Your draft is still intact.</p>
<p className="font-semibold">{t("A newer database configuration is available.")}</p>
<p className="mt-1 leading-5">{t("Reload the latest values before saving, testing, or deleting. Your draft is still intact.")}</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={onReloadLatest}>
<RefreshCw /> Reload latest
</Button>
<RefreshCw /> {t("Reload latest")}</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onKeepEditing}>
Keep editing
</Button>
{t("Keep editing")}</Button>
</div>
</div>
) : (
<div className="rounded-md border border-amber-300/70 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="status">
Reload the latest values before this configuration can be changed.
</div>
{t("Reload the latest values before this configuration can be changed.")}</div>
)
) : null}
{partialSecretFailure ? (
<div className="rounded-md border border-amber-300/70 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="alert">
<p className="font-semibold">Database configuration saved; secret update could not be confirmed.</p>
<p className="mt-1 leading-5">{partialSecretFailure} The values you entered are retained in this form.</p>
<p className="font-semibold">{t("Database configuration saved; secret update could not be confirmed.")}</p>
<p className="mt-1 leading-5">{t(partialSecretFailure)} {t("The values you entered are retained in this form.")}</p>
<Button type="button" size="sm" variant="outline" className="mt-3" disabled={busy || stale} onClick={onRetrySecrets}>
<RefreshCw /> {busyAction === "retry-secrets" ? "Retrying…" : "Retry secrets"}
<RefreshCw /> {busyAction === "retry-secrets" ? t("Retrying…") : t("Retry secrets")}
</Button>
</div>
) : null}
{editable && !canManageSecrets ? (
<div className="rounded-md border border-border bg-muted/45 px-4 py-3 text-sm text-muted-foreground" role="note">
Secret status is visible, but replacing secret values requires the workspace.secrets.manage permission.
</div>
{t("Secret status is visible, but replacing secret values requires the workspace.secrets.manage permission.")}</div>
) : null}
{!row.workspaceAvailable ? (
<div className="rounded-md border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive" role="alert">
This configuration references a workspace that is no longer present in the repository YAML.
</div>
{t("This configuration references a workspace that is no longer present in the repository YAML.")}</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<Field label="Workspace">
<Field label={t("Workspace")}>
<input className={inputClass} value={row.workspaceName} readOnly />
</Field>
<Field label="Workspace ID">
<Field label={t("Workspace ID")}>
<input className={`${inputClass} font-mono text-xs`} value={row.workspaceId} readOnly />
</Field>
<Field label="Database engine">
<Field label={t("Database engine")}>
<input className={inputClass} value="PostgreSQL" readOnly />
</Field>
<Field label="Transport">
<Field label={t("Transport")}>
<select
className={inputClass}
aria-label="Transport"
aria-label={t("Transport")}
value={draft.binding.transport}
disabled={readOnly || busy}
onChange={(event) => onTransportChange(event.target.value as DatabaseTransport)}
>
<option value="postgres_direct">Direct PostgreSQL connection</option>
<option value="rest_api">REST API</option>
<option value="ssh_tunnel">SSH tunnel</option>
<option value="postgres_direct">{t("Direct PostgreSQL connection")}</option>
<option value="rest_api">{t("REST API")}</option>
<option value="ssh_tunnel">{t("SSH tunnel")}</option>
</select>
</Field>
<Field label="Database name">
<Field label={t("Database name")}>
<input
className={inputClass}
required={editable}
@@ -294,7 +292,7 @@ export function DatabaseForm({
onChange={(event) => onFieldChange("databaseName", event.target.value)}
/>
</Field>
<Field label="Schema">
<Field label={t("Schema")}>
<input
className={inputClass}
required={editable}
@@ -307,44 +305,44 @@ export function DatabaseForm({
</div>
<div className="border-t border-border pt-5">
<p className="thot-label mb-4">Installation binding</p>
<p className="thot-label mb-4">{t("Installation binding")}</p>
{draft.binding.transport === "postgres_direct" ? (
<div className="grid gap-4 md:grid-cols-2">
<Field label="Host">
<Field label={t("Host")}>
<input className={inputClass} required={editable} value={draft.binding.host ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("host", event.target.value)} />
</Field>
<Field label="Port">
<Field label={t("Port")}>
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.port ?? 5432} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("port", Number(event.target.value))} />
</Field>
<Field label="Username">
<Field label={t("Username")}>
<input className={inputClass} required={editable} value={draft.binding.username ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("username", event.target.value)} />
</Field>
<Field label="TLS server name">
<Field label={t("TLS server name")}>
<input className={inputClass} value={draft.binding.tlsServername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("tlsServername", event.target.value || undefined)} />
</Field>
<SecretField label="Password" name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<SecretField label="TLS CA certificate" name="tlsCa" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<SecretField label={t("Password")} name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<SecretField label={t("TLS CA certificate")} name="tlsCa" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
</div>
) : null}
{draft.binding.transport === "rest_api" ? (
<div className="grid gap-4 md:grid-cols-2">
<Field label="Base URL">
<Field label={t("Base URL")}>
<input className={inputClass} type="url" required={editable} placeholder="https://dwh.example/api" value={draft.binding.baseUrl ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("baseUrl", event.target.value)} />
</Field>
<Field label="Diagnostic endpoint" hint="This relative path is fixed by the runtime contract.">
<input className={inputClass} value={draft.binding.restPath ?? "/health"} readOnly aria-label="Diagnostic endpoint" />
<Field label={t("Diagnostic endpoint")} hint={t("This relative path is fixed by the runtime contract.")}>
<input className={inputClass} value={draft.binding.restPath ?? "/health"} readOnly aria-label={t("Diagnostic endpoint")} />
</Field>
<Field label="Authentication">
<select className={inputClass} aria-label="Authentication" value={draft.binding.restAuth ?? "x-api-key"} disabled={readOnly || busy} onChange={(event) => onBindingChange("restAuth", event.target.value as DatabaseBinding["restAuth"])}>
<Field label={t("Authentication")}>
<select className={inputClass} aria-label={t("Authentication")} value={draft.binding.restAuth ?? "x-api-key"} disabled={readOnly || busy} onChange={(event) => onBindingChange("restAuth", event.target.value as DatabaseBinding["restAuth"])}>
<option value="x-api-key">X-API-Key</option>
<option value="bearer">Bearer token</option>
<option value="none">None</option>
<option value="bearer">{t("Bearer token")}</option>
<option value="none">{t("None")}</option>
</select>
</Field>
{draft.binding.restAuth !== "none" ? (
<SecretField label="API key" name="apiKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<SecretField label={t("API key")} name="apiKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
) : null}
</div>
) : null}
@@ -352,63 +350,62 @@ export function DatabaseForm({
{draft.binding.transport === "ssh_tunnel" ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-md border border-border bg-muted/45 px-4 py-3 text-sm text-muted-foreground md:col-span-2" role="note">
SSH host verification is strict. Provide the trusted known_hosts entry before testing this binding.
</div>
<Field label="Database username">
{t("SSH host verification is strict. Provide the trusted known_hosts entry before testing this binding.")}</div>
<Field label={t("Database username")}>
<input className={inputClass} required={editable} value={draft.binding.username ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("username", event.target.value)} />
</Field>
<SecretField label="Database password" name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<Field label="SSH host">
<SecretField label={t("Database password")} name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<Field label={t("SSH host")}>
<input className={inputClass} required={editable} value={draft.binding.sshHost ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshHost", event.target.value)} />
</Field>
<Field label="SSH port">
<Field label={t("SSH port")}>
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.sshPort ?? 22} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshPort", Number(event.target.value))} />
</Field>
<Field label="SSH username">
<Field label={t("SSH username")}>
<input className={inputClass} required={editable} value={draft.binding.sshUsername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshUsername", event.target.value)} />
</Field>
<SecretField label="SSH private key" name="sshPrivateKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
<SecretField label="Private key passphrase" name="sshPrivateKeyPassphrase" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<SecretField label="SSH known hosts" name="sshKnownHosts" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
<Field label="Target host">
<SecretField label={t("SSH private key")} name="sshPrivateKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
<SecretField label={t("Private key passphrase")} name="sshPrivateKeyPassphrase" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<SecretField label={t("SSH known hosts")} name="sshKnownHosts" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
<Field label={t("Target host")}>
<input className={inputClass} required={editable} value={draft.binding.sshTargetHost ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshTargetHost", event.target.value)} />
</Field>
<Field label="Target port">
<Field label={t("Target port")}>
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.sshTargetPort ?? 5432} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshTargetPort", Number(event.target.value))} />
</Field>
<Field label="TLS server name">
<Field label={t("TLS server name")}>
<input className={inputClass} value={draft.binding.tlsServername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("tlsServername", event.target.value || undefined)} />
</Field>
<SecretField label="TLS CA certificate" name="tlsCa" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
<SecretField label={t("TLS CA certificate")} name="tlsCa" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
</div>
) : null}
</div>
{row.lastErrorMessage ? (
<div className="rounded-md border border-destructive/25 bg-destructive/8 px-4 py-3 text-sm text-destructive" role="status">
Last connection error: {row.lastErrorMessage}
{t("Last connection error:")} {t(row.lastErrorMessage)}
</div>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-4">
{mode === "view" || mode === "edit" ? (
<Button type="button" variant="outline" disabled={testDisabled} onClick={onTest}>
<TestTube2 /> {busyAction === "test" ? "Testing…" : "Test connection"}
<TestTube2 /> {busyAction === "test" ? t("Testing…") : t("Test connection")}
</Button>
) : null}
{mode === "configure" || mode === "edit" ? (
<Button type="submit" disabled={saveDisabled}>
<Save />
{busyAction === "save"
? "Saving…"
? t("Saving…")
: mode === "configure"
? "Save catalog configuration"
: "Save changes"}
? t("Save catalog configuration")
: t("Save changes")}
</Button>
) : null}
{mode === "delete" ? (
<Button type="button" variant="destructive" disabled={!canManage || !row.configured || !row.id || busy || stale} onClick={onDelete}>
<Trash2 /> {busyAction === "delete" ? "Removing…" : presentation === "drawer" ? "Remove configuration" : "Delete database"}
<Trash2 /> {busyAction === "delete" ? t("Removing…") : presentation === "drawer" ? t("Remove configuration") : t("Delete database")}
</Button>
) : null}
</div>
@@ -417,33 +414,30 @@ export function DatabaseForm({
if (presentation === "drawer") {
return (
<section aria-label={`${title} form`} className="thot-fleet-drawer-form">
<section aria-label={t("{title} form", { title })} className="thot-fleet-drawer-form">
<h2 ref={headingRef} tabIndex={-1} className="sr-only">{title}</h2>
<div className="thot-fleet-drawer-form__context">
<div className="min-w-0">
<p className="thot-label">Workspace</p>
<p className="thot-label">{t("Workspace")}</p>
<p className="mt-1 truncate text-sm font-semibold">{row.workspaceName}</p>
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">{row.workspaceId}</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Database className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="rounded-md border border-border bg-muted px-2.5 py-1 text-xs font-semibold text-muted-foreground">
{statusLabel(row)}
{t(statusLabel(row))}
</span>
{mode === "view" && row.configured ? (
<Button type="button" variant="outline" size="sm" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
{activeSyncRun ? <RefreshCw className="animate-spin" /> : <History />} Sync history
</Button>
<Button type="button" variant="outline" size="sm" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip={t("Synchronization history")} onClick={onOpenSync}>
{activeSyncRun ? <RefreshCw className="animate-spin" /> : <History />} {t("Sync history")}</Button>
) : null}
{mode === "view" && row.configured && onOpenDescriptionHistory ? (
<Button type="button" variant="outline" size="sm" onClick={onOpenDescriptionHistory}>
<History /> Description history
</Button>
<History /> {t("Description history")}</Button>
) : null}
{mode === "view" && row.configured && onOpenSensitiveHistory ? (
<Button type="button" variant="outline" size="sm" onClick={onOpenSensitiveHistory}>
<History /> Sensitive history
</Button>
<History /> {t("Sensitive history")}</Button>
) : null}
{mode === "view" ? (
<DatabaseSyncMenu
@@ -451,12 +445,12 @@ export function DatabaseForm({
onSelect={onSync}
/>
) : null}
{mode === "view" && row.configured && onOpenDescriptionHistory ? <Button type="button" variant="outline" onClick={onOpenDescriptionHistory}><History /> Description history</Button> : null}
{mode === "view" && row.configured && onOpenSensitiveHistory ? <Button type="button" variant="outline" onClick={onOpenSensitiveHistory}><History /> Sensitive history</Button> : null}
{mode === "view" && row.configured && onOpenDescriptionHistory ? <Button type="button" variant="outline" onClick={onOpenDescriptionHistory}><History /> {t("Description history")}</Button> : null}
{mode === "view" && row.configured && onOpenSensitiveHistory ? <Button type="button" variant="outline" onClick={onOpenSensitiveHistory}><History /> {t("Sensitive history")}</Button> : null}
</div>
</div>
{row.lastTestedAt ? (
<p className="mb-4 text-xs text-muted-foreground">Last tested {new Date(row.lastTestedAt).toLocaleString()}</p>
<p className="mb-4 text-xs text-muted-foreground">{t("Last tested")} {new Date(row.lastTestedAt).toLocaleString(getLocale())}</p>
) : null}
{form}
</section>
@@ -464,15 +458,14 @@ export function DatabaseForm({
}
return (
<section aria-label={`${title} form`} className="min-h-0 flex-1 overflow-y-auto bg-background">
<section aria-label={t("{title} form", { title })} className="min-h-0 flex-1 overflow-y-auto bg-background">
<div className="mx-auto w-full max-w-[900px] px-4 py-5 sm:px-6 sm:py-6">
<Button type="button" variant="ghost" className="-ml-2 mb-5" disabled={busy} onClick={onBack}>
<ArrowLeft /> Back to list
</Button>
<ArrowLeft /> {t("Back to list")}</Button>
<div className="mb-6 flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
<div className="min-w-0">
<p className="thot-label mb-1">Database management</p>
<p className="thot-label mb-1">{t("Database management")}</p>
<h2 ref={headingRef} tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight outline-none">
{title}
</h2>
@@ -480,16 +473,16 @@ export function DatabaseForm({
{row.workspaceName} · {row.workspaceId}
</p>
{row.lastTestedAt ? (
<p className="mt-1 text-xs text-muted-foreground">Last tested {new Date(row.lastTestedAt).toLocaleString()}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("Last tested")} {new Date(row.lastTestedAt).toLocaleString(getLocale())}</p>
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Database className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="rounded-full border border-border bg-muted px-2.5 py-1 text-xs font-semibold text-muted-foreground">
{statusLabel(row)}
{t(statusLabel(row))}
</span>
{mode === "view" && activeSyncRun ? (
<Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button>
<Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />{t("View sync")}</Button>
) : null}
{mode === "view" ? (
<DatabaseSyncMenu
@@ -501,15 +494,14 @@ export function DatabaseForm({
</div>
{mode === "view" ? (
<nav aria-label="Database sections" className="mb-6 flex gap-1 border-b border-border" role="tablist">
<nav aria-label={t("Database sections")} className="mb-6 flex gap-1 border-b border-border" role="tablist">
<button
type="button"
role="tab"
aria-selected="true"
className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground"
>
Overview
</button>
{t("Overview")}</button>
<button
type="button"
role="tab"
@@ -518,8 +510,7 @@ export function DatabaseForm({
className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-45"
onClick={(event) => onOpenTables(event.currentTarget)}
>
Tables
</button>
{t("Tables")}</button>
<button
type="button"
role="tab"
@@ -528,8 +519,7 @@ export function DatabaseForm({
className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-45"
onClick={onOpenRelationships}
>
Relationships
</button>
{t("Relationships")}</button>
</nav>
) : null}
@@ -1,3 +1,5 @@
import { useAdminGridLocale } from "./useAdminGridLocale";
import { useI18n, translate } from "../../i18n";
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useQueries } from "@tanstack/react-query";
import { Menu } from "@base-ui/react/menu";
@@ -91,8 +93,8 @@ function useCompactViewport(): boolean {
const query = window.matchMedia("(max-width: 767px)");
const update = () => setCompact(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
query.addEventListener(translate("change"), update);
return () => query.removeEventListener(translate("change"), update);
}, []);
return compact;
@@ -119,19 +121,20 @@ function StateCell({
tone: StateTone;
detailClassName?: string;
}) {
const { t } = useI18n();
return (
<div className="flex h-full min-w-0 flex-col justify-center gap-0.5 py-1 leading-tight">
<span className="flex min-w-0 items-center gap-1.5 text-xs font-semibold text-foreground">
<span className={`size-1.5 shrink-0 rounded-full ${stateToneClass[tone]}`} aria-hidden="true" />
<span className="truncate">{label}</span>
<span className="truncate">{t(label)}</span>
</span>
<span className={`truncate pl-3 text-[11px] ${detailClassName}`}>{detail}</span>
<span className={`truncate pl-3 text-[11px] ${detailClassName}`}>{t(detail)}</span>
</div>
);
}
function endpoint(host: string | undefined, port: number | undefined): string {
if (!host) return "Endpoint not configured";
if (!host) return translate("Endpoint not configured");
return port ? `${host}:${port}` : host;
}
@@ -140,7 +143,7 @@ function accessSummary(row: CatalogDatabase): { label: string; detail: string }
? row.binding.transport
: row.runtimeBinding?.transport ?? row.binding.transport;
if (transport === "rest_api") {
if (!row.binding.baseUrl) return { label: "REST", detail: "Endpoint not configured" };
if (!row.binding.baseUrl) return { label: "REST", detail: translate("Endpoint not configured") };
try {
return { label: "REST", detail: new URL(row.binding.baseUrl).host };
} catch {
@@ -151,41 +154,42 @@ function accessSummary(row: CatalogDatabase): { label: string; detail: string }
return { label: "SSH", detail: endpoint(row.binding.sshHost, row.binding.sshPort) };
}
return {
label: "Direct PostgreSQL connection",
label: translate("Direct PostgreSQL connection"),
detail: endpoint(row.binding.host, row.binding.port),
};
}
function coverageStatus(total: number, complete: number): { label: string; detail: string; tone: StateTone } {
if (total === 0) return { label: "Not started", detail: "No catalog objects", tone: "danger" };
if (complete >= total) return { label: "Complete", detail: `${complete}/${total}`, tone: "success" };
if (complete > 0) return { label: "Partial", detail: `${complete}/${total}`, tone: "warning" };
return { label: "Not started", detail: `0/${total}`, tone: "danger" };
if (total === 0) return { label: translate("Not started"), detail: translate("No catalog objects"), tone: "danger" };
if (complete >= total) return { label: translate("Complete"), detail: `${complete}/${total}`, tone: "success" };
if (complete > 0) return { label: translate("Partial"), detail: `${complete}/${total}`, tone: "warning" };
return { label: translate("Not started"), detail: `0/${total}`, tone: "danger" };
}
function CatalogStatusCells({ row, metrics, sensitiveRun }: { row: CatalogDatabase; metrics?: CatalogMetrics; sensitiveRun?: SensitivityAnalysisRun }) {
const { t } = useI18n();
const access = accessSummary(row);
const synchronized = row.schemaSyncedVersion === row.version;
const syncStatus = !row.schemaSyncedVersion
? { label: "Not synchronized", detail: "Schema not synchronized", tone: "danger" as const }
? { label: t("Not synchronized"), detail: t("Schema not synchronized"), tone: "danger" as const }
: synchronized
? { label: "Synchronized", detail: "Current schema", tone: "success" as const }
: { label: "Partial", detail: "Schema revision changed", tone: "warning" as const };
? { label: t("Synchronized"), detail: t("Current schema"), tone: "success" as const }
: { label: t("Partial"), detail: t("Schema revision changed"), tone: "warning" as const };
const descriptions = coverageStatus(metrics?.descriptionTargets ?? 0, metrics?.describedTargets ?? 0);
const sensitiveProcessed = sensitiveRun ? sensitiveRun.suggestedSensitive + sensitiveRun.suggestedNonSensitive : 0;
const sensitive = !sensitiveRun
? { label: "Not started", detail: "No analysis has been run", tone: "danger" as const }
? { label: t("Not started"), detail: t("No analysis has been run"), tone: "danger" as const }
: sensitiveRun.status === "completed" && sensitiveProcessed >= sensitiveRun.total
? { label: "Complete", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "success" as const }
? { label: t("Complete"), detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "success" as const }
: ["failed", "interrupted"].includes(sensitiveRun.status)
? { label: "Incomplete", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "danger" as const }
: { label: "Partial", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "warning" as const };
? { label: t("Incomplete"), detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "danger" as const }
: { label: t("Partial"), detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "warning" as const };
return (
<div className="grid h-full min-w-[540px] grid-cols-4 gap-3 py-1">
<StateCell label={access.label} detail={access.detail} tone="info" />
<StateCell label={syncStatus.label} detail={syncStatus.detail} tone={syncStatus.tone} />
<StateCell label={descriptions.label} detail={`Descriptions · ${descriptions.detail}`} tone={descriptions.tone} />
<StateCell label={sensitive.label} detail={`Sensitive · ${sensitive.detail}`} tone={sensitive.tone} />
<StateCell label={descriptions.label} detail={t("Descriptions · {detail}", { detail: descriptions.detail })} tone={descriptions.tone} />
<StateCell label={sensitive.label} detail={t("Sensitive · {detail}", { detail: sensitive.detail })} tone={sensitive.tone} />
</div>
);
}
@@ -194,6 +198,7 @@ function DatabaseActionsCell({
data,
context,
}: ICellRendererParams<CatalogDatabase, unknown, DatabaseGridContext>) {
const { t } = useI18n();
if (!data || !context) return null;
if (data.workspaceAvailable && !data.configured) {
@@ -205,9 +210,9 @@ function DatabaseActionsCell({
variant="ghost"
size="icon-lg"
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Details" : undefined}
title={context.presentation === "legacy" ? `View ${data.workspaceName}` : undefined}
aria-label={`View ${data.workspaceName}`}
data-tooltip={context.presentation === "fleet" ? t("Details") : undefined}
title={context.presentation === "legacy" ? t("View {workspaceName}", { workspaceName: data.workspaceName }) : undefined}
aria-label={t("View {workspaceName}", { workspaceName: data.workspaceName })}
onClick={(event) => context.onView(data, event.currentTarget)}
>
{context.presentation === "fleet" ? <Info aria-hidden="true" /> : <Eye aria-hidden="true" />}
@@ -216,17 +221,15 @@ function DatabaseActionsCell({
type="button"
size="sm"
className="whitespace-nowrap"
aria-label={`Configure catalog for ${data.workspaceName}`}
aria-label={t("Configure catalog for {workspaceName}", { workspaceName: data.workspaceName })}
aria-describedby={!context.canManage ? configureReasonId : undefined}
disabled={!context.canManage}
onClick={(event) => context.onEdit(data, event.currentTarget)}
>
Configure catalog
</Button>
{t("Configure catalog")}</Button>
{!context.canManage ? (
<span id={configureReasonId} className="sr-only">
Database management permission is required.
</span>
{t("Database management permission is required.")}</span>
) : null}
</div>
);
@@ -234,7 +237,7 @@ function DatabaseActionsCell({
const editDisabled = !context.canManage || !data.workspaceAvailable;
const deleteDisabled = !context.canManage || !data.configured;
const editLabel = `Edit ${data.workspaceName}`;
const editLabel = t("Edit {workspaceName}", { workspaceName: data.workspaceName });
const editReasonId = `database-edit-reason-${data.workspaceId}`;
const deleteReasonId = `database-delete-reason-${data.workspaceId}`;
const relationshipsReasonId = `database-relationships-reason-${data.workspaceId}`;
@@ -242,12 +245,12 @@ function DatabaseActionsCell({
return (
<div className="flex h-full items-center justify-end gap-0.5" onClick={(event) => event.stopPropagation()}>
{context.presentation === "fleet" && context.onOpenTables ? (
<span className="thot-fleet-icon-action inline-flex" data-tooltip="Tables">
<span className="thot-fleet-icon-action inline-flex" data-tooltip={t("Tables")}>
<Button
type="button"
variant="ghost"
size="icon-lg"
aria-label={`View tables for ${data.workspaceName}`}
aria-label={t("View tables for {workspaceName}", { workspaceName: data.workspaceName })}
disabled={!data.configured || !data.id}
onClick={(event) => context.onOpenTables?.(data, event.currentTarget)}
>
@@ -256,12 +259,12 @@ function DatabaseActionsCell({
</span>
) : null}
{context.presentation === "fleet" && context.onOpenRelationships ? (
<span className="thot-fleet-icon-action inline-flex" data-tooltip="Relationships">
<span className="thot-fleet-icon-action inline-flex" data-tooltip={t("Relationships")}>
<Button
type="button"
variant="ghost"
size="icon-lg"
aria-label={`View relationships for ${data.workspaceName}`}
aria-label={t("View relationships for {workspaceName}", { workspaceName: data.workspaceName })}
aria-describedby={!data.configured || !data.id ? relationshipsReasonId : undefined}
disabled={!data.configured || !data.id}
onClick={(event) => context.onOpenRelationships?.(data, event.currentTarget)}
@@ -270,8 +273,7 @@ function DatabaseActionsCell({
</Button>
{!data.configured || !data.id ? (
<span id={relationshipsReasonId} className="sr-only">
Save this database configuration before managing relationships.
</span>
{t("Save this database configuration before managing relationships.")}</span>
) : null}
</span>
) : null}
@@ -280,41 +282,41 @@ function DatabaseActionsCell({
variant="ghost"
size="icon-lg"
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Details" : undefined}
title={context.presentation === "legacy" ? `View ${data.workspaceName}` : undefined}
aria-label={`View ${data.workspaceName}`}
data-tooltip={context.presentation === "fleet" ? t("Details") : undefined}
title={context.presentation === "legacy" ? t("View {workspaceName}", { workspaceName: data.workspaceName }) : undefined}
aria-label={t("View {workspaceName}", { workspaceName: data.workspaceName })}
onClick={(event) => context.onView(data, event.currentTarget)}
>
{context.presentation === "fleet" ? <Info aria-hidden="true" /> : <Eye aria-hidden="true" />}
</Button>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Edit configuration" : undefined} title={context.presentation === "legacy" ? (editDisabled ? "This database configuration cannot be changed" : editLabel) : undefined}>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? t("Edit configuration") : undefined} title={context.presentation === "legacy" ? (editDisabled ? t("This database configuration cannot be changed") : editLabel) : undefined}>
<Button
type="button"
variant="ghost"
size="icon-lg"
aria-label={editLabel}
aria-label={t(editLabel)}
aria-describedby={editDisabled ? editReasonId : undefined}
disabled={editDisabled}
onClick={(event) => context.onEdit(data, event.currentTarget)}
>
<Pencil aria-hidden="true" />
</Button>
{editDisabled ? <span id={editReasonId} className="sr-only">This database configuration cannot be changed.</span> : null}
{editDisabled ? <span id={editReasonId} className="sr-only">{t("This database configuration cannot be changed.")}</span> : null}
</span>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Remove configuration" : undefined} title={context.presentation === "legacy" ? (deleteDisabled ? "Save this database configuration before deleting it" : `Delete ${data.workspaceName}`) : undefined}>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? t("Remove configuration") : undefined} title={context.presentation === "legacy" ? (deleteDisabled ? t("Save this database configuration before deleting it") : t("Delete {workspaceName}", { workspaceName: data.workspaceName })) : undefined}>
<Button
type="button"
variant="ghost"
size="icon-lg"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label={`Delete ${data.workspaceName}`}
aria-label={t("Delete {workspaceName}", { workspaceName: data.workspaceName })}
aria-describedby={deleteDisabled ? deleteReasonId : undefined}
disabled={deleteDisabled}
onClick={(event) => context.onDelete(data, event.currentTarget)}
>
<Trash2 aria-hidden="true" />
</Button>
{deleteDisabled ? <span id={deleteReasonId} className="sr-only">Save this database configuration before deleting it.</span> : null}
{deleteDisabled ? <span id={deleteReasonId} className="sr-only">{t("Save this database configuration before deleting it.")}</span> : null}
</span>
</div>
);
@@ -347,6 +349,8 @@ export function DatabaseGrid({
onRefresh,
presentation = "legacy",
}: DatabaseGridProps) {
const { t } = useI18n();
const { locale, gridLocaleProps } = useAdminGridLocale();
const metricQueries = useQueries({
queries: rows.filter((row) => row.id).map((row) => ({
queryKey: ["catalog-metrics", row.id],
@@ -362,10 +366,10 @@ export function DatabaseGrid({
if (metrics && row.id) result[row.id] = metrics;
});
return result;
}, [metricQueries, rows]);
}, [metricQueries, rows, t]);
const sensitiveRunsByDatabase = useMemo(() => new Map(
rows.filter((row) => row.id).map((row) => [row.id!, sensitivityAnalysisRuns.find((run) => run.databaseId === row.id)]),
), [rows, sensitivityAnalysisRuns]);
), [rows, sensitivityAnalysisRuns, t]);
const compact = useCompactViewport();
const gridRef = useRef<AgGridReact<CatalogDatabase>>(null);
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
@@ -379,19 +383,19 @@ export function DatabaseGrid({
const [pendingConsolidation, setPendingConsolidation] = useState(false);
const context = useMemo<DatabaseGridContext>(
() => ({ canManage, presentation, onView, onOpenTables, onOpenRelationships, onEdit, onDelete, onOpenSync }),
[canManage, presentation, onView, onOpenTables, onOpenRelationships, onEdit, onDelete, onOpenSync],
[canManage, presentation, onView, onOpenTables, onOpenRelationships, onEdit, onDelete, onOpenSync, t],
);
const columnDefs = useMemo<ColDef<CatalogDatabase>[]>(() => {
const workspace: ColDef<CatalogDatabase> = {
field: "workspaceName",
headerName: "Workspace",
headerName: t("Workspace"),
minWidth: compact ? 112 : 190,
flex: compact ? undefined : 1.35,
width: compact ? 112 : undefined,
};
const status: ColDef<CatalogDatabase> = {
headerName: "Catalog status",
headerName: t("Catalog status"),
minWidth: 580,
flex: 2.5,
cellRenderer: ({ data }: ICellRendererParams<CatalogDatabase>) => {
@@ -404,7 +408,7 @@ export function DatabaseGrid({
const actionsWidth = presentation === "fleet" ? 232 : 210;
const actions: ColDef<CatalogDatabase> = {
colId: "actions",
headerName: "Actions",
headerName: t("Actions"),
width: actionsWidth,
minWidth: actionsWidth,
maxWidth: actionsWidth,
@@ -420,7 +424,7 @@ export function DatabaseGrid({
};
return [workspace, status, actions];
}, [compact, metricsByDatabase, presentation, sensitiveRunsByDatabase]);
}, [compact, metricsByDatabase, presentation, sensitiveRunsByDatabase, t]);
const configuredCount = rows.filter((row) => row.configured).length;
const hiddenSelected = selectedRows.filter((row) => {
@@ -446,54 +450,54 @@ export function DatabaseGrid({
const canDeleteMetadataSelection = canManage && selectedRows.length > 0
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const fleetActions = useMemo<readonly FleetActionOption<DatabaseFleetAction>[]>(() => {
const permissionReason = !canManage ? "Database management permission is required" : undefined;
const permissionReason = !canManage ? t("Database management permission is required") : undefined;
const testReason = permissionReason
?? (selectedRows.some((row) => !row.configured || !row.id)
? "Every selected workspace must be configured"
? t("Every selected workspace must be configured")
: selectedRows.some((row) => row.activeSyncRun)
? "Wait for active synchronization"
? t("Wait for active synchronization")
: undefined);
const syncReason = permissionReason
?? (selectedRows.some((row) => !row.configured || !row.id)
? "Every selected workspace must be configured"
? t("Every selected workspace must be configured")
: selectedRows.some((row) => row.activeSyncRun)
? "Wait for active synchronization"
? t("Wait for active synchronization")
: undefined);
const generationReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (selectedRows.length !== 1 ? t("Select exactly one database") : undefined)
?? (!selectedMetadataModel ? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE : undefined)
?? (descriptionGenerationActive ? "Wait for active description generation" : undefined)
?? (descriptionGenerationActive ? t("Wait for active description generation") : undefined)
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
? t("The selected database must be configured and idle")
: undefined);
const consolidationReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (descriptionGenerationActive ? "Wait for active description generation" : undefined)
?? (selectedRows.length !== 1 ? t("Select exactly one database") : undefined)
?? (descriptionGenerationActive ? t("Wait for active description generation") : undefined)
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
? t("The selected database must be configured and idle")
: undefined);
const sensitiveReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (descriptionGenerationActive ? "Wait for the active metadata operation" : undefined)
?? (selectedRows.length !== 1 ? t("Select exactly one database") : undefined)
?? (descriptionGenerationActive ? t("Wait for the active metadata operation") : undefined)
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
? t("The selected database must be configured and idle")
: undefined);
const cleanupReason = permissionReason
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "Every selected database must be configured and idle"
? t("Every selected database must be configured and idle")
: undefined);
return [
{ id: "test", label: "Test connections", group: "Connection", disabled: !canTestSelection, disabledReason: testReason },
{ id: "sync-tables", label: "Synchronize tables", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-relationships", label: "Synchronize relationships", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-all", label: "Synchronize all", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "generate-missing", label: "Generate missing descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason },
{ id: "generate-all", label: "Generate all descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason, runLabel: "Review generation" },
{ id: "consolidate-descriptions", label: "Copy generated description to descriptions", group: "Descriptions", disabled: !canConsolidateDescriptions, disabledReason: consolidationReason, runLabel: "Review copy" },
{ id: "suggest-sensitive", label: "Analyze sensitive fields", group: "Sensitive data", disabled: !canSuggestSensitive, disabledReason: sensitiveReason, runLabel: "Open review" },
{ id: "clear-tables", label: "Clear catalog tables and relationships", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
{ id: "clear-relationships", label: "Clear catalog relationships", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
{ id: "test", label: t("Test connections"), group: t("Connection"), disabled: !canTestSelection, disabledReason: testReason },
{ id: "sync-tables", label: t("Synchronize tables"), group: t("Synchronization"), disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-relationships", label: t("Synchronize relationships"), group: t("Synchronization"), disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-all", label: t("Synchronize all"), group: t("Synchronization"), disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "generate-missing", label: t("Generate missing descriptions"), group: t("Descriptions"), disabled: !canGenerateDescriptions, disabledReason: generationReason },
{ id: "generate-all", label: t("Generate all descriptions"), group: t("Descriptions"), disabled: !canGenerateDescriptions, disabledReason: generationReason, runLabel: t("Review generation") },
{ id: "consolidate-descriptions", label: t("Copy generated description to descriptions"), group: t("Descriptions"), disabled: !canConsolidateDescriptions, disabledReason: consolidationReason, runLabel: t("Review copy") },
{ id: "suggest-sensitive", label: t("Analyze sensitive fields"), group: t("Sensitive data"), disabled: !canSuggestSensitive, disabledReason: sensitiveReason, runLabel: t("Open review") },
{ id: "clear-tables", label: t("Clear catalog tables and relationships"), group: t("Cleanup"), disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: t("Review cleanup") },
{ id: "clear-relationships", label: t("Clear catalog relationships"), group: t("Cleanup"), disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: t("Review cleanup") },
];
}, [
canConsolidateDescriptions,
@@ -506,7 +510,7 @@ export function DatabaseGrid({
descriptionGenerationActive,
selectedMetadataModel,
selectedRows,
]);
, t]);
useEffect(() => {
if (!pendingDelete) return;
const timer = window.setTimeout(() => document.getElementById("database-cleanup-confirm-button")?.focus(), 0);
@@ -587,7 +591,7 @@ export function DatabaseGrid({
return (
<section
aria-label="Workspace databases"
aria-label={t("Workspace databases")}
data-presentation={presentation}
className={presentation === "fleet"
? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card"
@@ -610,24 +614,21 @@ export function DatabaseGrid({
<div className="mr-auto min-w-56">
<p id="database-generation-confirmation" className="text-sm font-semibold">
{pendingGenerationScope === "all"
? `Replace generated descriptions for ${selectedRows[0]?.workspaceName}?`
: `Generate missing descriptions for ${selectedRows[0]?.workspaceName}?`}
? t("Replace generated descriptions for {workspaceName}?", { workspaceName: selectedRows[0]?.workspaceName })
: t("Generate missing descriptions for {workspaceName}?", { workspaceName: selectedRows[0]?.workspaceName })}
</p>
{pendingGenerationScope === "all" ? (
<p className="text-xs text-muted-foreground">
Existing generated descriptions for eligible tables and columns will be replaced.
</p>
{t("Existing generated descriptions for eligible tables and columns will be replaced.")}</p>
) : null}
<p
role="note"
aria-label="Metadata generation source data disclosure"
aria-label={t("Metadata generation source data disclosure")}
className="mt-1 text-xs font-medium text-amber-800 dark:text-amber-300"
>
When available, up to five real source rows and five representative values from
columns not marked sensitive are sent to the selected provider.
</p>
{t("When available, up to five real source rows and five representative values from columns not marked sensitive are sent to the selected provider.")}</p>
</div>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeGenerationConfirmation}>Cancel</Button>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeGenerationConfirmation}>{t("Cancel")}</Button>
<Button
id="database-generation-confirm-button"
type="button"
@@ -638,7 +639,7 @@ export function DatabaseGrid({
() => onGenerateDescriptions(selectedRows, pendingGenerationScope),
)}
>
{pendingGenerationScope === "all" ? "Generate all descriptions" : "Generate missing descriptions"}
{pendingGenerationScope === "all" ? t("Generate all descriptions") : t("Generate missing descriptions")}
</Button>
</div>
) : pendingConsolidation ? (
@@ -652,13 +653,12 @@ export function DatabaseGrid({
>
<div className="mr-auto min-w-56">
<p id="database-consolidation-confirmation" className="text-sm font-semibold">
Copy generated descriptions to Description fields in {selectedRows[0]?.workspaceName}?
{t("Copy generated descriptions to Description fields in")} {selectedRows[0]?.workspaceName}?
</p>
<p className="text-xs text-muted-foreground">
Every non-empty AI-generated table and column description will replace its current Description value. Generated descriptions are retained.
</p>
{t("Every non-empty AI-generated table and column description will replace its current Description value. Generated descriptions are retained.")}</p>
</div>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeConsolidationConfirmation}>Cancel</Button>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeConsolidationConfirmation}>{t("Cancel")}</Button>
<Button
id="database-consolidation-confirm-button"
type="button"
@@ -669,8 +669,7 @@ export function DatabaseGrid({
() => onConsolidateDescriptions(selectedRows),
)}
>
Copy to descriptions
</Button>
{t("Copy to descriptions")}</Button>
</div>
) : pendingDelete ? (
<div
@@ -684,16 +683,16 @@ export function DatabaseGrid({
<div className="mr-auto min-w-56">
<p id="database-cleanup-confirmation" className="text-sm font-semibold">
{pendingDelete === "tables"
? `${presentation === "fleet" ? "Clear" : "Delete all"} catalog tables for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`
: `${presentation === "fleet" ? "Clear catalog" : "Delete all"} relationships for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`}
? (selectedRows.length === 1 ? t("{value} catalog tables for {count} database?", { value: presentation === "fleet" ? t("Clear") : t("Delete all"), count: selectedRows.length }) : t("{value} catalog tables for {count} databases?", { value: presentation === "fleet" ? t("Clear") : t("Delete all"), count: selectedRows.length }))
: (selectedRows.length === 1 ? t("{value} relationships for {count} database?", { value: presentation === "fleet" ? t("Clear catalog") : t("Delete all"), count: selectedRows.length }) : t("{value} relationships for {count} databases?", { value: presentation === "fleet" ? t("Clear catalog") : t("Delete all"), count: selectedRows.length }))}
</p>
<p className="text-xs text-muted-foreground">
{pendingDelete === "tables"
? "Columns and relationships will also be removed. Database configurations and source databases are unchanged."
: "Catalog tables, columns, database configurations, and source databases are unchanged."}
? t("Columns and relationships will also be removed. Database configurations and source databases are unchanged.")
: t("Catalog tables, columns, database configurations, and source databases are unchanged.")}
</p>
</div>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeDeleteConfirmation}>{t("Cancel")}</Button>
<Button
id="database-cleanup-confirm-button"
type="button"
@@ -702,8 +701,8 @@ export function DatabaseGrid({
onClick={() => void perform("delete", () => onDeleteMetadataSelected(selectedRows, pendingDelete))}
>
<Trash2 />{pendingDelete === "tables"
? presentation === "fleet" ? "Clear catalog tables" : "Delete catalog tables"
: presentation === "fleet" ? "Clear catalog relationships" : "Delete relationships"}
? presentation === "fleet" ? t("Clear catalog tables") : t("Delete catalog tables")
: presentation === "fleet" ? t("Clear catalog relationships") : t("Delete relationships")}
</Button>
</div>
) : presentation === "fleet" ? (
@@ -713,7 +712,7 @@ export function DatabaseGrid({
selectedCount={selectedRows.length}
hiddenSelectedCount={hiddenSelected}
busy={action !== null}
busyLabel={action === "suggest" ? "Suggesting…" : "Running…"}
busyLabel={action === "suggest" ? t("Suggesting…") : t("Running…")}
selectRef={actionSelectRef}
onRun={runFleetAction}
onClear={() => {
@@ -724,14 +723,14 @@ export function DatabaseGrid({
</>
) : (
<>
<span className="text-sm font-semibold">{selectedRows.length} selected</span>
{hiddenSelected ? <span className="text-xs text-muted-foreground">{hiddenSelected} hidden by filter</span> : null}
<span className="text-sm font-semibold">{selectedRows.length} {t("selected")}</span>
{hiddenSelected ? <span className="text-xs text-muted-foreground">{hiddenSelected} {t("hidden by filter")}</span> : null}
<Menu.Root>
<Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={action !== null}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={action !== null}>{t("Actions")} <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canTestSelection} onClick={() => void perform("test", () => onTestSelected(selectedRows))}>Test connections</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canTestSelection} onClick={() => void perform("test", () => onTestSelected(selectedRows))}>{t("Test connections")}</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
{databaseSyncScopes.map(({ scope, label }) => (
<Menu.Item
@@ -740,7 +739,7 @@ export function DatabaseGrid({
disabled={!canSyncSelection}
onClick={() => void perform("sync", () => onSyncSelected(selectedRows, scope))}
>
{label}
{t(label)}
</Menu.Item>
))}
<Menu.Separator className="my-1 h-px bg-border" />
@@ -749,37 +748,32 @@ export function DatabaseGrid({
disabled={!canGenerateDescriptions}
onClick={() => setPendingGenerationScope("all")}
>
Generate all descriptions
</Menu.Item>
{t("Generate all descriptions")}</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canGenerateDescriptions}
onClick={() => setPendingGenerationScope("missing")}
>
Generate missing descriptions
</Menu.Item>
{t("Generate missing descriptions")}</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canConsolidateDescriptions}
onClick={() => setPendingConsolidation(true)}
>
Copy generated description to descriptions
</Menu.Item>
{t("Copy generated description to descriptions")}</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
disabled={!canDeleteMetadataSelection}
onClick={() => setPendingDelete("tables")}
>
Delete all tables
</Menu.Item>
{t("Delete all tables")}</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
disabled={!canDeleteMetadataSelection}
onClick={() => setPendingDelete("relationships")}
>
Delete all relationships
</Menu.Item>
{t("Delete all relationships")}</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
@@ -788,37 +782,35 @@ export function DatabaseGrid({
type="button"
variant="outline"
disabled={!canManage || action !== null || descriptionGenerationActive}
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
title={descriptionGenerationActive ? t("Wait for the active description generation to finish") : undefined}
onClick={() => void perform("suggest", () => onSuggestSensitive(selectedRows))}
>
<Sparkles />{action === "suggest" ? "Analyzing…" : "Analyze sensitive fields"}
<Sparkles />{action === "suggest" ? t("Analyzing…") : t("Analyze sensitive fields")}
</Button>
<Button type="button" variant="ghost" disabled={action !== null} onClick={() => { gridRef.current?.api.deselectAll(); setSelectedRows([]); }}><X />Clear</Button>
<Button type="button" variant="ghost" disabled={action !== null} onClick={() => { gridRef.current?.api.deselectAll(); setSelectedRows([]); }}><X />{t("Clear")}</Button>
</>
)
) : <><span className="thot-label whitespace-nowrap">Workspace databases</span><input
) : <><span className="thot-label whitespace-nowrap">{t("Workspace databases")}</span><input
ref={searchInputRef}
className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Search databases"
placeholder="Search"
aria-label={t("Search databases")}
placeholder={t("Search")}
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
{presentation === "fleet" && onRefresh ? (
<Button type="button" variant="outline" size="sm" disabled={isFetching} onClick={() => void onRefresh()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Refresh
</Button>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Refresh")}</Button>
) : null}</>}
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
{configuredCount}/{rows.length} catalogs configured
</span>
{configuredCount}/{rows.length} {t("catalogs configured")}</span>
{isFetching && !isLoading ? (
<span className="text-xs text-muted-foreground" role="status">Refreshing</span>
<span className="text-xs text-muted-foreground" role="status">{t("Refreshing")}</span>
) : null}
</div>
<div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full${presentation === "fleet" ? " thot-fleet-ledger-grid" : ""}`}>
<AgGridReact<CatalogDatabase>
<AgGridReact<CatalogDatabase> key={locale} {...gridLocaleProps}
ref={gridRef}
rowData={rows}
columnDefs={columnDefs}
@@ -849,12 +841,10 @@ export function DatabaseGrid({
</div>
{isLoading ? (
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-card/80 text-sm text-muted-foreground" role="status">
Loading database configurations…
</div>
{t("Loading database configurations…")}</div>
) : rows.length === 0 ? (
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-card px-6 text-center text-sm text-muted-foreground" role="status">
No repository workspaces are available for database configuration.
</div>
{t("No repository workspaces are available for database configuration.")}</div>
) : null}
</div>
</section>
@@ -1,3 +1,5 @@
import { useAdminGridLocale } from "./useAdminGridLocale";
import { useI18n, translate } from "../../i18n";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
@@ -52,10 +54,11 @@ function relationshipLabel(relationship: CatalogRelationship): string {
const pair = relationship.columns[0];
const source = `${relationship.sourceTableName}${pair ? `.${pair.sourceColumnName}` : ""}`;
const target = `${relationship.targetTableName}${pair ? `.${pair.targetColumnName}` : ""}`;
return `${source} to ${target}`;
return translate("{source} to {target}", { source, target });
}
function RelationshipActionsCell({ data, context }: ICellRendererParams<CatalogRelationship, unknown, RelationshipGridContext>) {
const { t } = useI18n();
if (!data || !context || context.presentation !== "fleet") return null;
return (
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
@@ -64,8 +67,8 @@ function RelationshipActionsCell({ data, context }: ICellRendererParams<CatalogR
variant="ghost"
size="icon-lg"
className="thot-fleet-icon-action"
data-tooltip="Details"
aria-label={`View relationship ${relationshipLabel(data)}`}
data-tooltip={t("Details")}
aria-label={t("View relationship {relationship}", { relationship: relationshipLabel(data) })}
onClick={(event) => context.onView(data, event.currentTarget)}
>
<Info aria-hidden="true" />
@@ -88,6 +91,8 @@ export function DatabaseRelationships({
onNavigationStateChange,
presentation = "legacy",
}: Props) {
const { t } = useI18n();
const { locale, gridLocaleProps } = useAdminGridLocale();
const databaseId = database.id!;
const queryClient = useQueryClient();
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
@@ -115,7 +120,7 @@ export function DatabaseRelationships({
: undefined;
const visibleRelationships = useMemo(() => statusFilter === "all"
? data
: data.filter((relationship) => relationship.status === statusFilter), [data, statusFilter]);
: data.filter((relationship) => relationship.status === statusFilter), [data, statusFilter, t]);
const tablesQuery = useQuery({
queryKey: ["catalog-tables", databaseId],
queryFn: () => listCatalogTables(databaseId),
@@ -148,16 +153,16 @@ export function DatabaseRelationships({
const selectedActionUnavailable = !selectedAction
? null
: !canManage
? "You do not have permission to manage catalog relationships."
? t("You do not have permission to manage catalog relationships.")
: database.activeSyncRun
? "Wait for the active synchronization to finish."
? t("Wait for the active synchronization to finish.")
: null;
const addUnavailable = !canManage
? "You do not have permission to add catalog relationships."
? t("You do not have permission to add catalog relationships.")
: database.activeSyncRun
? "Wait for the active synchronization to finish."
? t("Wait for the active synchronization to finish.")
: busy
? "Wait for the current relationship operation to finish."
? t("Wait for the current relationship operation to finish.")
: null;
const synchronize = async (scope: "relationships") => {
@@ -165,8 +170,8 @@ export function DatabaseRelationships({
try {
const run = await startCatalogSync(databaseId, database.version, scope);
onRunStarted(run);
toast.success("Relationship synchronization started");
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
toast.success(t("Relationship synchronization started"));
} catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(false); }
};
const openRelationship = (relationship: CatalogRelationship, origin: HTMLButtonElement) => {
@@ -200,10 +205,10 @@ export function DatabaseRelationships({
else statusFilterRef.current?.focus();
}, 0);
toast.success(status === "excluded"
? "Relationship excluded. Future builds will leave it excluded."
: "Relationship restored.");
? t("Relationship excluded. Future builds will leave it excluded.")
: t("Relationship restored."));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setBusy(false);
}
@@ -222,9 +227,9 @@ export function DatabaseRelationships({
if (originRef.current?.isConnected) originRef.current.focus();
else statusFilterRef.current?.focus();
}, 0);
toast.success("Relationship deleted permanently. A future build may infer it again.");
toast.success(t("Relationship deleted permanently. A future build may infer it again."));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setBusy(false);
}
@@ -256,7 +261,7 @@ export function DatabaseRelationships({
await onCatalogMetricsChanged?.();
setAddOpen(false);
resetAddForm();
toast.success("Relationship added.");
toast.success(t("Relationship added."));
} catch (error) {
if (error instanceof ApiError && error.code === "relationship_target_not_unique") {
setTargetColumnError("The target column must be the only primary-key column of its table.");
@@ -267,7 +272,7 @@ export function DatabaseRelationships({
} else {
setAddError(apiErrorMessage(error));
}
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setBusy(false);
}
@@ -286,10 +291,10 @@ export function DatabaseRelationships({
&& result.excluded === 0
&& result.ambiguous === 0;
toast.success(noFindings
? "Build complete: no new relationships found."
: `Build complete: ${result.added} added, ${result.alreadyPresent} already present, ${result.excluded} excluded, ${result.ambiguous} ambiguous.`);
? t("Build complete: no new relationships found.")
: t("Build complete: {added} added, {alreadyPresent} already present, {excluded} excluded, {ambiguous} ambiguous.", { added: result.added, alreadyPresent: result.alreadyPresent, excluded: result.excluded, ambiguous: result.ambiguous }));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setBusy(false);
}
@@ -300,79 +305,78 @@ export function DatabaseRelationships({
};
const columns = useMemo<ColDef<CatalogRelationship>[]>(() => [
...(presentation === "legacy" ? [{ field: "constraintName", headerName: "Constraint", minWidth: 220, flex: 1, cellClass: "font-mono text-xs" } satisfies ColDef<CatalogRelationship>] : []),
{ field: "sourceTableName", headerName: "Source table", minWidth: 200, flex: 1 },
{ headerName: "Source column", minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.sourceColumnName).join(", ") ?? "" },
{ field: "targetTableName", headerName: "Target table", minWidth: 200, flex: 1 },
{ headerName: "Target column", minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.targetColumnName).join(", ") ?? "" },
{ field: "origin", headerName: "Origin", minWidth: 120, width: 120, valueFormatter: ({ value }) => titleCase(String(value ?? "")) },
{ field: "status", headerName: "Status", minWidth: 110, width: 110, valueFormatter: ({ value }) => titleCase(String(value ?? "")) },
...(presentation === "legacy" ? [{ field: "constraintName", headerName: t("Constraint"), minWidth: 220, flex: 1, cellClass: "font-mono text-xs" } satisfies ColDef<CatalogRelationship>] : []),
{ field: "sourceTableName", headerName: t("Source table"), minWidth: 200, flex: 1 },
{ headerName: t("Source column"), minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.sourceColumnName).join(", ") ?? "" },
{ field: "targetTableName", headerName: t("Target table"), minWidth: 200, flex: 1 },
{ headerName: t("Target column"), minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.targetColumnName).join(", ") ?? "" },
{ field: "origin", headerName: t("Origin"), minWidth: 120, width: 120, valueFormatter: ({ value }) => t(titleCase(String(value ?? ""))) },
{ field: "status", headerName: t("Status"), minWidth: 110, width: 110, valueFormatter: ({ value }) => t(titleCase(String(value ?? ""))) },
...(presentation === "legacy" ? [
{ field: "updateRule", headerName: "On update", minWidth: 125, width: 125 },
{ field: "deleteRule", headerName: "On delete", minWidth: 125, width: 125 },
{ field: "updateRule", headerName: t("On update"), minWidth: 125, width: 125 },
{ field: "deleteRule", headerName: t("On delete"), minWidth: 125, width: 125 },
] satisfies ColDef<CatalogRelationship>[] : []),
...(presentation === "fleet" ? [{ colId: "actions", headerName: "Actions", width: 64, minWidth: 64, maxWidth: 64, pinned: "right" as const, lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: RelationshipActionsCell }] : []),
], [presentation]);
const context = useMemo<RelationshipGridContext>(() => ({ presentation, onView: openRelationship }), [data, presentation]);
...(presentation === "fleet" ? [{ colId: "actions", headerName: t("Actions"), width: 64, minWidth: 64, maxWidth: 64, pinned: "right" as const, lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: RelationshipActionsCell }] : []),
], [presentation, t]);
const context = useMemo<RelationshipGridContext>(() => ({ presentation, onView: openRelationship }), [data, presentation, t]);
const fleetQueryError = presentation === "fleet" && isError ? (
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
<p className="font-semibold text-destructive">Catalog relationships could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<p className="font-semibold text-destructive">{t("Catalog relationships could not be loaded.")}</p>
<p className="mt-1 leading-5 text-muted-foreground">{t(apiErrorMessage(error))}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry relationships
</Button>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Retry relationships")}</Button>
</div>
</div>
) : null;
return (
<section aria-label={`Relationships for ${database.workspaceName}`} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<section aria-label={t("Relationships for {workspaceName}", { workspaceName: database.workspaceName })} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
{presentation === "legacy" ? <div className="px-4 pt-5 sm:px-5">
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={onBackToDatabases}><ArrowLeft />Back to databases</Button>
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={onBackToDatabases}><ArrowLeft />{t("Back to databases")}</Button>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
<div>
<p className="thot-label mb-1">Database management</p>
<p className="thot-label mb-1">{t("Database management")}</p>
<h2 className="font-heading text-2xl font-semibold tracking-tight">{database.workspaceName}</h2>
<p className="mt-1 text-sm text-muted-foreground">{database.databaseName} · {database.schema}</p>
</div>
<div className="flex gap-2">
{database.activeSyncRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button> : null}
<Button type="button" disabled={!canManage || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("relationships")}><RefreshCw />Synchronize relationships</Button>
{database.activeSyncRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />{t("View sync")}</Button> : null}
<Button type="button" disabled={!canManage || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("relationships")}><RefreshCw />{t("Synchronize relationships")}</Button>
</div>
</div>
<nav aria-label="Database sections" className="flex gap-1 border-b border-border" role="tablist">
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenOverview}>Overview</button>
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenTables}>Tables</button>
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">Relationships</button>
<nav aria-label={t("Database sections")} className="flex gap-1 border-b border-border" role="tablist">
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenOverview}>{t("Overview")}</button>
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenTables}>{t("Tables")}</button>
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">{t("Relationships")}</button>
</nav>
</div> : null}
<div className={presentation === "fleet" ? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card" : "mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5"}>
<div className={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2`}>
<span className="thot-label whitespace-nowrap">Relationship map</span>
<label className="sr-only" htmlFor="relationship-status-filter">Relationship status</label>
<span className="thot-label whitespace-nowrap">{t("Relationship map")}</span>
<label className="sr-only" htmlFor="relationship-status-filter">{t("Relationship status")}</label>
<select
ref={statusFilterRef}
id="relationship-status-filter"
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Relationship status"
aria-label={t("Relationship status")}
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as RelationshipStatusFilter)}
>
<option value="active">Active</option>
<option value="excluded">Excluded</option>
<option value="all">All</option>
<option value="active">{t("Active")}</option>
<option value="excluded">{t("Excluded")}</option>
<option value="all">{t("All")}</option>
</select>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search relationships" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label={t("Search relationships")} placeholder={t("Search")} value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{visibleRelationships.length}</span>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />{t("Refresh")}</Button>
{presentation === "fleet" ? (
<div className="flex flex-wrap items-center gap-2">
<div className="thot-fleet-action-selector__scope" aria-live="polite">
<strong>{selectedAction === "relationships" ? "Physical relationships in this database" : "This database"}</strong>
<span>Action scope</span>
<strong>{selectedAction === "relationships" ? t("Physical relationships in this database") : t("This database")}</strong>
<span>{t("Action scope")}</span>
</div>
<label className="sr-only" htmlFor="relationship-action">Relationship action</label>
<label className="sr-only" htmlFor="relationship-action">{t("Relationship action")}</label>
<select
id="relationship-action"
className="thot-fleet-action-selector__select"
@@ -380,20 +384,19 @@ export function DatabaseRelationships({
disabled={busy}
onChange={(event) => setSelectedAction(event.target.value as "" | "rebuild-generated" | "relationships")}
>
<option value="">Choose an action…</option>
<optgroup label="Logical relationships">
<option value="rebuild-generated">Build generated relationships</option>
<option value="">{t("Choose an action…")}</option>
<optgroup label={t("Logical relationships")}>
<option value="rebuild-generated">{t("Build generated relationships")}</option>
</optgroup>
<optgroup label="Synchronization">
<option value="relationships">Synchronize physical relationships</option>
<optgroup label={t("Synchronization")}>
<option value="relationships">{t("Synchronize physical relationships")}</option>
</optgroup>
</select>
<Button type="button" size="sm" disabled={!selectedAction || Boolean(selectedActionUnavailable) || busy} aria-describedby={selectedActionUnavailable ? "relationship-action-reason" : undefined} onClick={() => void runSelectedAction()}><Play aria-hidden="true" />{busy ? selectedAction === "rebuild-generated" ? "Building…" : "Running…" : "Run action"}</Button>
<Button type="button" size="sm" variant="outline" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
{database.activeSyncRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />} Sync history
</Button>
{onOpenDescriptionHistory ? <Button type="button" size="sm" variant="outline" onClick={onOpenDescriptionHistory}><History aria-hidden="true" /> Description history</Button> : null}
{onOpenSensitiveHistory ? <Button type="button" size="sm" variant="outline" onClick={onOpenSensitiveHistory}><History aria-hidden="true" /> Sensitive history</Button> : null}
<Button type="button" size="sm" disabled={!selectedAction || Boolean(selectedActionUnavailable) || busy} aria-describedby={selectedActionUnavailable ? "relationship-action-reason" : undefined} onClick={() => void runSelectedAction()}><Play aria-hidden="true" />{busy ? selectedAction === "rebuild-generated" ? t("Building…") : t("Running…") : t("Run action")}</Button>
<Button type="button" size="sm" variant="outline" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip={t("Synchronization history")} onClick={onOpenSync}>
{database.activeSyncRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />} {t("Sync history")}</Button>
{onOpenDescriptionHistory ? <Button type="button" size="sm" variant="outline" onClick={onOpenDescriptionHistory}><History aria-hidden="true" /> {t("Description history")}</Button> : null}
{onOpenSensitiveHistory ? <Button type="button" size="sm" variant="outline" onClick={onOpenSensitiveHistory}><History aria-hidden="true" /> {t("Sensitive history")}</Button> : null}
<Button
type="button"
size="sm"
@@ -405,47 +408,46 @@ export function DatabaseRelationships({
setAddOpen(true);
}}
>
<Plus aria-hidden="true" /> Add relationship
</Button>
{addUnavailable ? <span id="relationship-add-reason" className="sr-only">{addUnavailable}</span> : null}
{selectedActionUnavailable ? <span id="relationship-action-reason" className="basis-full text-xs text-muted-foreground" role="status">{selectedActionUnavailable}</span> : null}
<Plus aria-hidden="true" /> {t("Add relationship")}</Button>
{addUnavailable ? <span id="relationship-add-reason" className="sr-only">{t(addUnavailable)}</span> : null}
{selectedActionUnavailable ? <span id="relationship-action-reason" className="basis-full text-xs text-muted-foreground" role="status">{t(selectedActionUnavailable)}</span> : null}
</div>
) : <Button type="button" disabled={!canManage || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("relationships")}><RefreshCw />Synchronize relationships</Button>}
) : <Button type="button" disabled={!canManage || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("relationships")}><RefreshCw />{t("Synchronize relationships")}</Button>}
</div>
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogRelationship> rowData={visibleRelationships} columnDefs={columns} context={context} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate={statusFilter === "active" ? "No relationships yet. Build from column names or add one manually." : statusFilter === "excluded" ? "No excluded relationships." : "No relationships for this database."} />
<AgGridReact<CatalogRelationship> key={locale} {...gridLocaleProps} rowData={visibleRelationships} columnDefs={columns} context={context} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate={statusFilter === "active" ? t("No relationships yet. Build from column names or add one manually.") : statusFilter === "excluded" ? t("No excluded relationships.") : t("No relationships for this database.")} />
</div>
</div>}
</div>
{presentation === "fleet" && addOpen ? (
<FleetLedgerDrawer
open
eyebrow="Logical relationship"
title="Add relationship"
description="Connect one source column to one primary-key target column."
eyebrow={t("Logical relationship")}
title={t("Add relationship")}
description={t("Connect one source column to one primary-key target column.")}
onClose={closeAdd}
closeLabel="Close relationship form"
closeLabel={t("Close relationship form")}
busy={busy}
footer={(
<>
<Button type="button" variant="outline" disabled={busy} onClick={closeAdd}>Cancel</Button>
<Button type="button" variant="outline" disabled={busy} onClick={closeAdd}>{t("Cancel")}</Button>
<Button
type="button"
disabled={busy || !sourceTableId || !sourceColumnId || !targetTableId || !targetColumnId}
onClick={() => void addRelationship()}
>
{busy ? "Adding…" : "Add relationship"}
{busy ? t("Adding…") : t("Add relationship")}
</Button>
</>
)}
>
<div className="grid gap-4 text-sm sm:grid-cols-2">
<label className="grid gap-1.5">
<span className="font-semibold">Source table</span>
<span className="font-semibold">{t("Source table")}</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Source table"
aria-label={t("Source table")}
value={sourceTableId}
disabled={busy || tablesQuery.isLoading}
onChange={(event) => {
@@ -454,15 +456,15 @@ export function DatabaseRelationships({
setAddError(null);
}}
>
<option value="">Select source table…</option>
<option value="">{t("Select source table…")}</option>
{(tablesQuery.data ?? []).map((table) => <option key={table.id} value={table.id}>{table.name}</option>)}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-semibold">Source column</span>
<span className="font-semibold">{t("Source column")}</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Source column"
aria-label={t("Source column")}
value={sourceColumnId}
disabled={busy || !sourceTableId || sourceColumnsQuery.isLoading}
onChange={(event) => {
@@ -471,15 +473,15 @@ export function DatabaseRelationships({
setTargetColumnError(null);
}}
>
<option value="">Select source column…</option>
<option value="">{t("Select source column…")}</option>
{(sourceColumnsQuery.data ?? []).map((column) => <option key={column.id} value={column.id}>{column.name}</option>)}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-semibold">Target table</span>
<span className="font-semibold">{t("Target table")}</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Target table"
aria-label={t("Target table")}
value={targetTableId}
disabled={busy || tablesQuery.isLoading}
onChange={(event) => {
@@ -489,15 +491,15 @@ export function DatabaseRelationships({
setTargetColumnError(null);
}}
>
<option value="">Select target table…</option>
<option value="">{t("Select target table…")}</option>
{(tablesQuery.data ?? []).map((table) => <option key={table.id} value={table.id}>{table.name}</option>)}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-semibold">Target column</span>
<span className="font-semibold">{t("Target column")}</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Target column"
aria-label={t("Target column")}
aria-invalid={Boolean(targetColumnError)}
aria-describedby={targetColumnError ? "target-column-error" : undefined}
value={targetColumnId}
@@ -508,30 +510,30 @@ export function DatabaseRelationships({
setTargetColumnError(null);
}}
>
<option value="">Select target column…</option>
<option value="">{t("Select target column…")}</option>
{targetColumns.map((column) => <option key={column.id} value={column.id}>{column.name}</option>)}
</select>
{targetColumnError ? <span id="target-column-error" className="text-xs text-destructive" role="alert">{targetColumnError}</span> : null}
{targetColumnError ? <span id="target-column-error" className="text-xs text-destructive" role="alert">{t(targetColumnError)}</span> : null}
</label>
{tablesQuery.isError || sourceColumnsQuery.isError || targetColumnsQuery.isError ? (
<p className="sm:col-span-2 text-sm text-destructive" role="alert">Catalog tables or columns could not be loaded.</p>
<p className="sm:col-span-2 text-sm text-destructive" role="alert">{t("Catalog tables or columns could not be loaded.")}</p>
) : null}
{addError ? <p className="sm:col-span-2 text-sm text-destructive" role="alert">{addError}</p> : null}
{addError ? <p className="sm:col-span-2 text-sm text-destructive" role="alert">{t(addError)}</p> : null}
</div>
</FleetLedgerDrawer>
) : null}
{presentation === "fleet" && activeRelationship ? (
<FleetLedgerDrawer
open
eyebrow={`${titleCase(activeRelationship.origin)} relationship`}
title="Relationship details"
eyebrow={t("{origin} relationship", { origin: t(titleCase(activeRelationship.origin)) })}
title={t("Relationship details")}
description={activeRelationship.origin === "physical"
? "This information is synchronized from the source schema and is read-only."
? t("This information is synchronized from the source schema and is read-only.")
: activeRelationship.origin === "generated"
? "This relationship was inferred from catalog column names."
: "This relationship was added manually."}
? t("This relationship was inferred from catalog column names.")
: t("This relationship was added manually.")}
onClose={closeRelationship}
closeLabel="Close relationship details"
closeLabel={t("Close relationship details")}
busy={busy}
footer={pendingMutation ? (
<div
@@ -544,10 +546,10 @@ export function DatabaseRelationships({
>
<p id="relationship-mutation-confirmation" className="mr-auto max-w-xl text-sm text-muted-foreground">
{pendingMutation === "exclude"
? "The relationship will move to Excluded. Builds will not recreate it, and you can restore it later."
: "The relationship record will be erased. A future build may infer it again."}
? t("The relationship will move to Excluded. Builds will not recreate it, and you can restore it later.")
: t("The relationship record will be erased. A future build may infer it again.")}
</p>
<Button type="button" variant="ghost" disabled={busy} onClick={() => setPendingMutation(null)}>Cancel</Button>
<Button type="button" variant="ghost" disabled={busy} onClick={() => setPendingMutation(null)}>{t("Cancel")}</Button>
<Button
id="relationship-mutation-confirm-button"
type="button"
@@ -557,36 +559,36 @@ export function DatabaseRelationships({
? changeRelationshipStatus("excluded")
: permanentlyDeleteRelationship())}
>
{pendingMutation === "exclude" ? "Exclude relationship" : "Delete permanently"}
{pendingMutation === "exclude" ? t("Exclude relationship") : t("Delete permanently")}
</Button>
</div>
) : (
<>
<Button type="button" variant="outline" disabled={busy} onClick={closeRelationship}>Close</Button>
<Button type="button" variant="outline" disabled={busy} onClick={closeRelationship}>{t("Close")}</Button>
{activeRelationship.origin !== "physical" && activeRelationship.status === "active" ? (
<Button type="button" variant="outline" disabled={!canManage || busy} onClick={() => setPendingMutation("exclude")}>Exclude</Button>
<Button type="button" variant="outline" disabled={!canManage || busy} onClick={() => setPendingMutation("exclude")}>{t("Exclude")}</Button>
) : null}
{activeRelationship.origin !== "physical" && activeRelationship.status === "excluded" ? (
<Button type="button" disabled={!canManage || busy} onClick={() => void changeRelationshipStatus("active")}>Restore</Button>
<Button type="button" disabled={!canManage || busy} onClick={() => void changeRelationshipStatus("active")}>{t("Restore")}</Button>
) : null}
{activeRelationship.origin !== "physical" ? (
<Button type="button" variant="destructive" disabled={!canManage || busy} onClick={() => setPendingMutation("delete")}>Delete permanently</Button>
<Button type="button" variant="destructive" disabled={!canManage || busy} onClick={() => setPendingMutation("delete")}>{t("Delete permanently")}</Button>
) : null}
</>
)}
>
<dl className="grid gap-4 text-sm sm:grid-cols-2">
<div><dt className="thot-label">Origin</dt><dd className="mt-1">{titleCase(activeRelationship.origin)}</dd></div>
<div><dt className="thot-label">Status</dt><dd className="mt-1">{titleCase(activeRelationship.status)}</dd></div>
{activeRelationship.constraintName ? <div className="sm:col-span-2"><dt className="thot-label">Constraint</dt><dd className="mt-1 break-words font-mono text-xs">{activeRelationship.constraintName}</dd></div> : null}
<div><dt className="thot-label">Source table</dt><dd className="mt-1 break-words">{activeRelationship.sourceTableName}</dd></div>
<div><dt className="thot-label">Target table</dt><dd className="mt-1 break-words">{activeRelationship.targetTableName}</dd></div>
{activeRelationship.origin === "physical" ? <><div><dt className="thot-label">On update</dt><dd className="mt-1">{activeRelationship.updateRule ?? "Not specified"}</dd></div><div><dt className="thot-label">On delete</dt><dd className="mt-1">{activeRelationship.deleteRule ?? "Not specified"}</dd></div></> : null}
<div><dt className="thot-label">{t("Origin")}</dt><dd className="mt-1">{t(titleCase(activeRelationship.origin))}</dd></div>
<div><dt className="thot-label">{t("Status")}</dt><dd className="mt-1">{t(titleCase(activeRelationship.status))}</dd></div>
{activeRelationship.constraintName ? <div className="sm:col-span-2"><dt className="thot-label">{t("Constraint")}</dt><dd className="mt-1 break-words font-mono text-xs">{activeRelationship.constraintName}</dd></div> : null}
<div><dt className="thot-label">{t("Source table")}</dt><dd className="mt-1 break-words">{activeRelationship.sourceTableName}</dd></div>
<div><dt className="thot-label">{t("Target table")}</dt><dd className="mt-1 break-words">{activeRelationship.targetTableName}</dd></div>
{activeRelationship.origin === "physical" ? <><div><dt className="thot-label">{t("On update")}</dt><dd className="mt-1">{activeRelationship.updateRule ?? t("Not specified")}</dd></div><div><dt className="thot-label">{t("On delete")}</dt><dd className="mt-1">{activeRelationship.deleteRule ?? t("Not specified")}</dd></div></> : null}
<div className="sm:col-span-2">
<dt className="thot-label">Column mapping</dt>
<dt className="thot-label">{t("Column mapping")}</dt>
<dd className="mt-2 overflow-hidden rounded-md border border-border">
<table className="w-full text-left text-xs">
<thead className="bg-muted/60 font-mono uppercase tracking-wide text-muted-foreground"><tr><th className="px-3 py-2">Source</th><th className="px-3 py-2">Target</th></tr></thead>
<thead className="bg-muted/60 font-mono uppercase tracking-wide text-muted-foreground"><tr><th className="px-3 py-2">{t("Source")}</th><th className="px-3 py-2">{t("Target")}</th></tr></thead>
<tbody>{activeRelationship.columns.map((pair) => <tr key={`${pair.sourceColumnName}:${pair.targetColumnName}`} className="border-t border-border"><td className="px-3 py-2 font-mono">{pair.sourceColumnName}</td><td className="px-3 py-2 font-mono">{pair.targetColumnName}</td></tr>)}</tbody>
</table>
</dd>
@@ -1,3 +1,4 @@
import { useI18n } from "../../i18n";
import { Menu } from "@base-ui/react/menu";
import { ChevronDown, RefreshCw } from "lucide-react";
import { buttonVariants } from "../../components/ui/button";
@@ -23,16 +24,17 @@ interface DatabaseSyncMenuProps {
}
export function DatabaseSyncMenu({ disabled, disabledReason, onSelect }: DatabaseSyncMenuProps) {
const { t } = useI18n();
return (
<Menu.Root>
<Menu.Trigger
type="button"
className={buttonVariants()}
disabled={disabled}
title={disabledReason}
aria-label="Synchronize database schema"
title={disabledReason ? t(disabledReason) : undefined}
aria-label={t("Synchronize database schema")}
>
<RefreshCw />Synchronize<ChevronDown />
<RefreshCw />{t("Synchronize")}<ChevronDown />
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="end" sideOffset={4}>
@@ -44,7 +46,7 @@ export function DatabaseSyncMenu({ disabled, disabledReason, onSelect }: Databas
disabled={disabled}
onClick={() => onSelect(scope)}
>
{label}
{t(label)}
</Menu.Item>
))}
</Menu.Popup>
@@ -1,3 +1,6 @@
import { useAdminGridLocale } from "./useAdminGridLocale";
import { adminMessage, type AdminMessage } from "./messages";
import { useI18n } from "../../i18n";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Menu } from "@base-ui/react/menu";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -43,7 +46,7 @@ interface Props {
onOpenSync: () => void;
onClearCatalogTables?: () => Promise<void>;
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
onSuggestSensitive: (selection: SensitivityAnalysisRequest, scopeLabel: string) => Promise<void>;
onSuggestSensitive: (selection: SensitivityAnalysisRequest, scopeLabel: AdminMessage) => Promise<void>;
onCatalogMetricsChanged?: () => void | Promise<void>;
presentation?: "legacy" | "fleet";
}
@@ -55,6 +58,7 @@ interface TableGridContext {
}
function TableActionsCell({ data, context }: ICellRendererParams<CatalogTable, unknown, TableGridContext>) {
const { t } = useI18n();
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-end gap-0.5" onClick={(event) => event.stopPropagation()}>
@@ -63,18 +67,18 @@ function TableActionsCell({ data, context }: ICellRendererParams<CatalogTable, u
variant="ghost"
size="icon-lg"
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Columns" : undefined}
aria-label={`View columns for ${data.name}`}
title={context.presentation === "legacy" ? `View columns for ${data.name}` : undefined}
data-tooltip={context.presentation === "fleet" ? t("Columns") : undefined}
aria-label={t("View columns for {name}", { name: data.name })}
title={context.presentation === "legacy" ? t("View columns for {name}", { name: data.name }) : undefined}
onClick={(event) => context.onOpen(data, "columns", event.currentTarget)}
>
<Columns3 aria-hidden="true" />
</Button>
<span
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Edit metadata" : undefined}
data-tooltip={context.presentation === "fleet" ? t("Edit metadata") : undefined}
>
<Button type="button" variant="ghost" size="icon-lg" aria-label={`Edit description for ${data.name}`} title={context.presentation === "legacy" ? `Edit metadata for ${data.name}` : undefined} disabled={!context.canManage} onClick={(event) => context.onOpen(data, "overview", event.currentTarget)}>
<Button type="button" variant="ghost" size="icon-lg" aria-label={t("Edit description for {name}", { name: data.name })} title={context.presentation === "legacy" ? t("Edit metadata for {name}", { name: data.name }) : undefined} disabled={!context.canManage} onClick={(event) => context.onOpen(data, "overview", event.currentTarget)}>
<Pencil aria-hidden="true" />
</Button>
</span>
@@ -101,6 +105,8 @@ export function DatabaseTables({
onCatalogMetricsChanged,
presentation = "legacy",
}: Props) {
const { t } = useI18n();
const { locale, gridLocaleProps } = useAdminGridLocale();
const databaseId = database.id!;
const queryClient = useQueryClient();
const queryKey = ["catalog-tables", databaseId] as const;
@@ -133,16 +139,16 @@ export function DatabaseTables({
const nestedNavigationActive = presentation === "fleet" && Boolean(activeTable) && tableSection === "columns";
const currentRun = activeRun ?? database.activeSyncRun;
const clearAllCatalogTables = async () => {
if (!onClearCatalogTables || !window.confirm("Clear all catalog tables, columns, and relationships for this database? The source database will not be changed.")) return;
if (!onClearCatalogTables || !window.confirm(t("Clear all catalog tables, columns, and relationships for this database? The source database will not be changed."))) return;
await onClearCatalogTables();
};
const leaveTable = useCallback(() => {
if (navigationBusy) return;
if (dirty && !window.confirm("Discard unsaved metadata?")) return;
if (dirty && !window.confirm(t("Discard unsaved metadata?"))) return;
setActiveTableId(null);
setTableSection("overview");
window.setTimeout(() => originRef.current?.focus(), 0);
}, [dirty, navigationBusy]);
}, [dirty, navigationBusy, t]);
useEffect(() => { onNavigationStateChange({ dirty, busy: navigationBusy }); }, [dirty, navigationBusy, onNavigationStateChange]);
useEffect(() => {
@@ -181,7 +187,7 @@ export function DatabaseTables({
};
const navigateDatabase = (target: "databases" | "overview" | "relationships") => {
if (navigationBusy) return;
if (dirty && !window.confirm("Discard unsaved metadata?")) return;
if (dirty && !window.confirm(t("Discard unsaved metadata?"))) return;
if (target === "databases") onBackToDatabases();
else if (target === "overview") onOpenOverview();
else onOpenRelationships();
@@ -201,13 +207,13 @@ export function DatabaseTables({
setEditorVersion(updated.version);
setStale(false);
void onCatalogMetricsChanged?.();
toast.success("Table metadata saved");
toast.success(t("Table metadata saved"));
} catch (error) {
if (error instanceof ApiError && error.code === "table_stale") {
await refetch();
setStale(true);
setStaleBannerOpen(true);
} else toast.error(apiErrorMessage(error));
} else toast.error(t(apiErrorMessage(error)));
} finally { setBusy(null); }
};
const reloadTable = async () => {
@@ -223,7 +229,7 @@ export function DatabaseTables({
setEditorVersion(latest.version);
setStale(false);
setStaleBannerOpen(true);
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(null); }
} catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(null); }
};
const synchronize = async (scope: "tables" | "columns" | "all", tableIds: string[] = []) => {
setBusy("sync");
@@ -232,8 +238,8 @@ export function DatabaseTables({
onRunStarted(run);
gridRef.current?.api.deselectAll();
setSelectedIds([]);
toast.success(scope === "all" ? "Full schema synchronization started" : scope === "columns" ? "Column synchronization started" : "Table synchronization started");
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(null); }
toast.success(scope === "all" ? t("Full schema synchronization started") : scope === "columns" ? t("Column synchronization started") : t("Table synchronization started"));
} catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(null); }
};
const deleteMetadata = async (target: CatalogTableMetadataDeleteTarget) => {
setBusy("delete");
@@ -250,10 +256,10 @@ export function DatabaseTables({
window.setTimeout(() => searchInputRef.current?.focus(), 0);
void onCatalogMetricsChanged?.();
toast.success(target === "columns"
? `${presentation === "fleet" ? "Cleared" : "Deleted"} ${counts.columns} catalog columns`
: `${presentation === "fleet" ? "Cleared" : "Deleted"} ${counts.relationships} catalog relationships`);
? t("{value} {columns} catalog columns", { value: presentation === "fleet" ? t("Cleared") : t("Deleted"), columns: counts.columns })
: t("{value} {relationships} catalog relationships", { value: presentation === "fleet" ? t("Cleared") : t("Deleted"), relationships: counts.relationships }));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally { setBusy(null); }
};
const consolidateDescriptions = async () => {
@@ -264,9 +270,9 @@ export function DatabaseTables({
gridRef.current?.api.deselectAll();
setSelectedIds([]);
void onCatalogMetricsChanged?.();
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
toast.success((counts.copied === 1 ? t("Copied {copied} description; skipped {skipped}", { copied: counts.copied, skipped: counts.skipped }) : t("Copied {copied} descriptions; skipped {skipped}", { copied: counts.copied, skipped: counts.skipped })));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally { setBusy(null); }
};
const generateDescriptions = async () => {
@@ -280,9 +286,9 @@ export function DatabaseTables({
selectedIds,
);
onDescriptionGenerationRunStarted(run);
toast.success(`Description generation started for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}`);
toast.success((selectedIds.length === 1 ? t("Description generation started for {count} table", { count: selectedIds.length }) : t("Description generation started for {count} tables", { count: selectedIds.length })));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally { setBusy(null); }
};
const generateColumnDescriptions = async () => {
@@ -292,7 +298,7 @@ export function DatabaseTables({
const columns = (await Promise.all(selectedIds.map((tableId) => listCatalogColumns(databaseId, tableId)))).flat();
const columnIds = columns.map((column) => column.id);
if (columnIds.length === 0) {
toast.error("The selected tables have no synchronized catalog columns.");
toast.error(t("The selected tables have no synchronized catalog columns."));
return;
}
const run = await startDescriptionGenerationRun(
@@ -302,9 +308,9 @@ export function DatabaseTables({
columnIds,
);
onDescriptionGenerationRunStarted(run);
toast.success(`Column description generation started for ${columnIds.length} column${columnIds.length === 1 ? "" : "s"}`);
toast.success((columnIds.length === 1 ? t("Column description generation started for {count} column", { count: columnIds.length }) : t("Column description generation started for {count} columns", { count: columnIds.length })));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally { setBusy(null); }
};
const suggestSensitive = async () => {
@@ -313,7 +319,7 @@ export function DatabaseTables({
try {
await onSuggestSensitive(
{ scope: "selected_tables", targetIds: selectedIds },
`${selectedIds.length} selected table${selectedIds.length === 1 ? "" : "s"}`,
(selectedIds.length === 1 ? adminMessage("{count} selected table", { count: selectedIds.length }) : adminMessage("{count} selected tables", { count: selectedIds.length })),
);
} catch {
// The page-level request reports the safe, specific reason and preserves the selection.
@@ -334,112 +340,112 @@ export function DatabaseTables({
const fleetActions: readonly FleetActionOption<FleetTableAction>[] = [
{
id: "generate-descriptions",
label: "Generate table description",
group: "Descriptions",
runLabel: "Generate",
label: t("Generate table description"),
group: t("Descriptions"),
runLabel: t("Generate"),
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to generate descriptions."
? t("You do not have permission to generate descriptions.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: !selectedMetadataModel
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: descriptionGenerationActive || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy !== null
? "Another action is running."
? t("Another action is running.")
: undefined,
},
{
id: "generate-column-descriptions",
label: "Generate column descriptions",
group: "Descriptions",
runLabel: "Generate",
label: t("Generate column descriptions"),
group: t("Descriptions"),
runLabel: t("Generate"),
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to generate descriptions."
? t("You do not have permission to generate descriptions.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: !selectedMetadataModel
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: descriptionGenerationActive || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy !== null
? "Another action is running."
? t("Another action is running.")
: undefined,
},
{
id: "consolidate-descriptions",
label: "Copy generated descriptions to Description",
group: "Descriptions",
runLabel: "Move",
label: t("Copy generated descriptions to Description"),
group: t("Descriptions"),
runLabel: t("Move"),
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to update metadata."
? t("You do not have permission to update metadata.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: undefined,
},
{
id: "sync-tables",
label: "Synchronize database tables",
group: "Synchronization",
scopeLabel: "Selected database",
runLabel: "Synchronize",
label: t("Synchronize database tables"),
group: t("Synchronization"),
scopeLabel: t("Selected database"),
runLabel: t("Synchronize"),
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
? t("You do not have permission to synchronize the catalog.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: undefined,
},
{
id: "sync-columns",
label: "Synchronize columns for selected tables",
group: "Synchronization",
runLabel: "Synchronize",
label: t("Synchronize columns for selected tables"),
group: t("Synchronization"),
runLabel: t("Synchronize"),
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
? t("You do not have permission to synchronize the catalog.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: undefined,
},
{
id: "suggest-sensitive",
label: "Analyze sensitive fields",
group: "Sensitive data",
runLabel: "Analyze",
label: t("Analyze sensitive fields"),
group: t("Sensitive data"),
runLabel: t("Analyze"),
disabled: !canManage || selectedIds.length === 0 || descriptionGenerationActive || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to review sensitive data."
? t("You do not have permission to review sensitive data.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: descriptionGenerationActive || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy !== null
? "Another action is running."
? t("Another action is running.")
: undefined,
},
{
id: "clear-columns",
label: "Remove synchronized columns",
group: "Catalog cleanup",
label: t("Remove synchronized columns"),
group: t("Catalog cleanup"),
tone: "destructive",
runLabel: "Review cleanup",
runLabel: t("Review cleanup"),
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to clear catalog metadata."
? t("You do not have permission to clear catalog metadata.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: undefined,
},
];
@@ -460,51 +466,50 @@ export function DatabaseTables({
};
const columns = useMemo<ColDef<CatalogTable>[]>(() => [
{ field: "name", headerName: "Name", minWidth: 250, flex: 1, cellClass: "font-mono text-xs" },
{ field: "sourceComment", headerName: "Source comment", minWidth: 260, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ field: "generatedDescription", headerName: "Generated description", minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ field: "description", headerName: "Description", minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: "Actions", width: 104, minWidth: 104, maxWidth: 104, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: TableActionsCell },
], []);
const context = useMemo<TableGridContext>(() => ({ canManage, presentation, onOpen: openTable }), [canManage, data, presentation]);
{ field: "name", headerName: t("Name"), minWidth: 250, flex: 1, cellClass: "font-mono text-xs" },
{ field: "sourceComment", headerName: t("Source comment"), minWidth: 260, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ field: "generatedDescription", headerName: t("Generated description"), minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ field: "description", headerName: t("Description"), minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: t("Actions"), width: 104, minWidth: 104, maxWidth: 104, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: TableActionsCell },
], [t]);
const context = useMemo<TableGridContext>(() => ({ canManage, presentation, onOpen: openTable }), [canManage, data, presentation, t]);
const visibleSelected = useMemo(() => {
const term = search.trim().toLocaleLowerCase();
if (!term) return selectedIds.length;
const selected = new Set(selectedIds);
return data.filter((table) => selected.has(table.id) && [table.name, table.sourceComment, table.generatedDescription, table.description]
.some((value) => value?.toLocaleLowerCase().includes(term))).length;
}, [data, search, selectedIds]);
}, [data, search, selectedIds, t]);
const fleetQueryError = presentation === "fleet" && isError ? (
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
<p className="font-semibold text-destructive">Catalog tables could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<p className="font-semibold text-destructive">{t("Catalog tables could not be loaded.")}</p>
<p className="mt-1 leading-5 text-muted-foreground">{t(apiErrorMessage(error))}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry tables
</Button>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Retry tables")}</Button>
</div>
</div>
) : null;
const databaseHeader = (
<div className="px-4 pt-5 sm:px-5">
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={navigationBusy} onClick={() => navigateDatabase("databases")}><ArrowLeft />Back to databases</Button>
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={navigationBusy} onClick={() => navigateDatabase("databases")}><ArrowLeft />{t("Back to databases")}</Button>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
<div>
<p className="thot-label mb-1">Database management</p>
<p className="thot-label mb-1">{t("Database management")}</p>
<h2 ref={headingRef} tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight outline-none">{database.workspaceName}</h2>
<p className="mt-1 text-sm text-muted-foreground">{database.databaseName} · {database.schema}</p>
</div>
<div className="flex gap-2">
{currentRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button> : null}
{onClearCatalogTables ? <Button type="button" variant="outline" className="text-destructive" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void clearAllCatalogTables()}>Clear catalog tables</Button> : null}
<Button type="button" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("all")}><RefreshCw />Sync all</Button>
{currentRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />{t("View sync")}</Button> : null}
{onClearCatalogTables ? <Button type="button" variant="outline" className="text-destructive" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void clearAllCatalogTables()}>{t("Clear catalog tables")}</Button> : null}
<Button type="button" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("all")}><RefreshCw />{t("Sync all")}</Button>
</div>
</div>
<nav aria-label="Database sections" className="flex gap-1 border-b border-border" role="tablist">
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={() => navigateDatabase("overview")}>Overview</button>
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">Tables</button>
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={() => navigateDatabase("relationships")}>Relationships</button>
<nav aria-label={t("Database sections")} className="flex gap-1 border-b border-border" role="tablist">
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={() => navigateDatabase("overview")}>{t("Overview")}</button>
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">{t("Tables")}</button>
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={() => navigateDatabase("relationships")}>{t("Relationships")}</button>
</nav>
</div>
);
@@ -512,10 +517,10 @@ export function DatabaseTables({
if (activeTable && presentation === "fleet") {
if (tableSection === "columns") {
return (
<section aria-label={`Columns for ${activeTable.name}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<section aria-label={t("Columns for {name}", { name: activeTable.name })} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border bg-muted/25 px-3 py-2">
<div className="min-w-0">
<p className="thot-label">Physical table</p>
<p className="thot-label">{t("Physical table")}</p>
<h3 className="truncate font-heading text-lg font-semibold">{activeTable.name}</h3>
</div>
</div>
@@ -539,27 +544,26 @@ export function DatabaseTables({
}
return (
<section aria-label={`Tables for ${database.workspaceName}`} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<section aria-label={t("Tables for {workspaceName}", { workspaceName: database.workspaceName })} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<div className="thot-fleet-grid-toolbar flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
<span className="thot-label whitespace-nowrap">Catalog tables</span>
<span className="thot-label whitespace-nowrap">{t("Catalog tables")}</span>
<input
ref={searchInputRef}
className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Search tables"
placeholder="Search"
aria-label={t("Search tables")}
placeholder={t("Search")}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}
{search.trim() ? t("{count} of {total}", { count: displayedCount, total: data.length }) : displayedCount}
</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> Refresh
</Button>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> {t("Refresh")}</Button>
</div>
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid thot-fleet-ledger-grid ag-theme-alpine absolute inset-0 h-full w-full">
<AgGridReact<CatalogTable>
<AgGridReact<CatalogTable> key={locale} {...gridLocaleProps}
ref={gridRef}
rowData={data}
columnDefs={columns}
@@ -574,43 +578,43 @@ export function DatabaseTables({
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No catalog tables. Synchronize the schema from the database view."
overlayNoRowsTemplate={t("No catalog tables. Synchronize the schema from the database view.")}
/>
</div>
</div>}
<FleetLedgerDrawer
open
eyebrow="Catalog metadata"
title="Review table description"
description="Physical schema fields are read-only. Review fields can be edited."
eyebrow={t("Catalog metadata")}
title={t("Review table description")}
description={t("Physical schema fields are read-only. Review fields can be edited.")}
onClose={leaveTable}
closeLabel="Close table metadata"
closeLabel={t("Close table metadata")}
busy={busy !== null}
footer={(
<>
<Button type="button" variant="outline" disabled={busy !== null} onClick={leaveTable}>Cancel</Button>
<Button type="button" variant="outline" disabled={busy !== null} onClick={leaveTable}>{t("Cancel")}</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy !== null || stale} onClick={() => void save()}>
<Save aria-hidden="true" />{busy === "save" ? "Saving…" : "Save metadata"}
<Save aria-hidden="true" />{busy === "save" ? t("Saving…") : t("Save metadata")}
</Button>
</>
)}
>
<p className="thot-label">Physical table</p>
<p className="thot-label">{t("Physical table")}</p>
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{activeTable.name}</h3>
{stale ? (
staleBannerOpen ? (
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm">
<p className="font-semibold">A newer table description is available.</p>
<p className="mt-1 text-muted-foreground">Your draft is preserved until you reload the current metadata.</p>
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div>
<p className="font-semibold">{t("A newer table description is available.")}</p>
<p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload the current metadata.")}</p>
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div>
</div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this description can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button></div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
) : null}
<div className="mt-5 grid gap-4">
<label className="grid gap-1.5 text-sm font-semibold">Physical table name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={activeTable.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Generated description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy !== null} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold">Description<textarea className="min-h-36 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy !== null} onChange={(event) => setDescription(event.target.value)} /><span className="font-normal text-muted-foreground">Leave blank to keep the curated description empty.</span></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Physical table name")}<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Source comment")}<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={activeTable.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Generated description")}<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy !== null} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Description")}<textarea className="min-h-36 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy !== null} onChange={(event) => setDescription(event.target.value)} /><span className="font-normal text-muted-foreground">{t("Leave blank to keep the curated description empty.")}</span></label>
</div>
</FleetLedgerDrawer>
</section>
@@ -619,16 +623,16 @@ export function DatabaseTables({
if (activeTable) {
return (
<section aria-label={`Table ${activeTable.name}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<section aria-label={t("Table {name}", { name: activeTable.name })} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
{databaseHeader}
<div className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5">
<div className="border-b border-border px-4 pt-4">
<Button type="button" variant="ghost" className="-ml-2 mb-2" onClick={leaveTable}><ArrowLeft />Back to tables</Button>
<p className="thot-label">Physical table</p>
<Button type="button" variant="ghost" className="-ml-2 mb-2" onClick={leaveTable}><ArrowLeft />{t("Back to tables")}</Button>
<p className="thot-label">{t("Physical table")}</p>
<h3 className="mt-1 font-heading text-xl font-semibold">{activeTable.name}</h3>
<nav aria-label="Table sections" className="mt-3 flex gap-1" role="tablist">
<button type="button" role="tab" aria-selected={tableSection === "overview"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "overview" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("overview")}>Overview</button>
<button type="button" role="tab" aria-selected={tableSection === "columns"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "columns" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("columns")}>Columns</button>
<nav aria-label={t("Table sections")} className="mt-3 flex gap-1" role="tablist">
<button type="button" role="tab" aria-selected={tableSection === "overview"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "overview" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("overview")}>{t("Overview")}</button>
<button type="button" role="tab" aria-selected={tableSection === "columns"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "columns" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("columns")}>{t("Columns")}</button>
</nav>
</div>
{tableSection === "columns" ? (
@@ -647,25 +651,25 @@ export function DatabaseTables({
/>
) : (
<div className="min-h-0 overflow-y-auto px-4 py-5">
<p className="text-sm text-muted-foreground">Only review metadata can be changed.</p>
<p className="text-sm text-muted-foreground">{t("Only review metadata can be changed.")}</p>
{stale ? (
staleBannerOpen ? (
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm">
<p className="font-semibold">A newer table description is available.</p>
<p className="mt-1 text-muted-foreground">Your draft is preserved until you reload the current metadata.</p>
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div>
<p className="font-semibold">{t("A newer table description is available.")}</p>
<p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload the current metadata.")}</p>
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div>
</div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this description can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button></div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
) : null}
<div className="mt-5 grid gap-4">
<label className="grid gap-1.5 text-sm font-semibold">Physical table name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={activeTable.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Generated description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy !== null} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold">Description<textarea className="min-h-36 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy !== null} onChange={(event) => setDescription(event.target.value)} /><span className="font-normal text-muted-foreground">Leave blank to keep the curated description empty.</span></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Physical table name")}<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Source comment")}<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={activeTable.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Generated description")}<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy !== null} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Description")}<textarea className="min-h-36 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy !== null} onChange={(event) => setDescription(event.target.value)} /><span className="font-normal text-muted-foreground">{t("Leave blank to keep the curated description empty.")}</span></label>
</div>
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" disabled={busy !== null} onClick={leaveTable}>Cancel</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy !== null || stale} onClick={() => void save()}><Save />{busy === "save" ? "Saving…" : "Save metadata"}</Button>
<Button type="button" variant="outline" disabled={busy !== null} onClick={leaveTable}>{t("Cancel")}</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy !== null || stale} onClick={() => void save()}><Save />{busy === "save" ? t("Saving…") : t("Save metadata")}</Button>
</div>
</div>
)}
@@ -675,7 +679,7 @@ export function DatabaseTables({
}
return (
<section aria-label={`Tables for ${database.workspaceName}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<section aria-label={t("Tables for {workspaceName}", { workspaceName: database.workspaceName })} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
{presentation === "legacy" ? databaseHeader : null}
<div className={presentation === "fleet" ? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card" : "mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5"}>
<div className={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2`}>
@@ -692,14 +696,13 @@ export function DatabaseTables({
<div className="mr-auto min-w-56">
<p id="table-cleanup-confirmation" className="text-sm font-semibold">
{presentation === "fleet"
? `Remove synchronized columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
: `Remove synchronized columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`}
? (selectedIds.length === 1 ? t("Remove synchronized columns for {count} table?", { count: selectedIds.length }) : t("Remove synchronized columns for {count} tables?", { count: selectedIds.length }))
: (selectedIds.length === 1 ? t("Remove synchronized columns for {count} table?", { count: selectedIds.length }) : t("Remove synchronized columns for {count} tables?", { count: selectedIds.length }))}
</p>
<p className="text-xs text-muted-foreground">
Only synchronized catalog columns for these tables will be removed. The source database is not modified.
</p>
{t("Only synchronized catalog columns for these tables will be removed. The source database is not modified.")}</p>
</div>
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>{t("Cancel")}</Button>
<Button
id="table-cleanup-confirm-button"
type="button"
@@ -707,8 +710,7 @@ export function DatabaseTables({
disabled={busy !== null}
onClick={() => void deleteMetadata(pendingDelete)}
>
<Trash2 />Remove synchronized columns
</Button>
<Trash2 />{t("Remove synchronized columns")}</Button>
</div>
) : (
presentation === "fleet" ? (
@@ -718,35 +720,35 @@ export function DatabaseTables({
selectedCount={selectedIds.length}
hiddenSelectedCount={selectedIds.length - visibleSelected}
busy={busy !== null}
busyLabel={busy === "generate" ? "Generating…" : busy === "suggest" ? "Suggesting…" : "Running…"}
busyLabel={busy === "generate" ? t("Generating…") : busy === "suggest" ? t("Suggesting…") : t("Running…")}
selectRef={fleetActionSelectRef}
onRun={runFleetAction}
onClear={clearSelection}
label="Table action"
label={t("Table action")}
/>
</>
) : (
<>
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
{visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} hidden by filter</span> : null}
<span className="text-sm font-semibold">{selectedIds.length} {t("selected")}</span>
{visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} {t("hidden by filter")}</span> : null}
<Menu.Root>
<Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy !== null}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy !== null}>{t("Actions")} <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>Generate table description</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateColumnDescriptions()}>Generate descriptions for all columns</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("tables")}>Synchronize database tables</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>Synchronize columns for selected tables</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>Copy generated descriptions to Description</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>{t("Generate table description")}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateColumnDescriptions()}>{t("Generate descriptions for all columns")}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("tables")}>{t("Synchronize database tables")}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>{t("Synchronize columns for selected tables")}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>{t("Copy generated descriptions to Description")}</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>Remove synchronized columns</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>{t("Remove synchronized columns")}</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy !== null} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{busy === "suggest" ? "Analyzing…" : "Analyze sensitive fields"}</Button>
<Button type="button" variant="ghost" onClick={clearSelection}><X />Clear</Button>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy !== null} title={descriptionGenerationActive ? t("Wait for the active description generation to finish") : undefined} onClick={() => void suggestSensitive()}><Sparkles />{busy === "suggest" ? t("Analyzing…") : t("Analyze sensitive fields")}</Button>
<Button type="button" variant="ghost" onClick={clearSelection}><X />{t("Clear")}</Button>
</>
)
)
@@ -757,29 +759,29 @@ export function DatabaseTables({
actions={fleetActions}
selectedCount={0}
busy={busy !== null}
busyLabel={busy === "generate" ? "Generating…" : busy === "suggest" ? "Suggesting…" : "Running…"}
busyLabel={busy === "generate" ? t("Generating…") : busy === "suggest" ? t("Suggesting…") : t("Running…")}
selectRef={fleetActionSelectRef}
onRun={runFleetAction}
onClear={clearSelection}
label="Table action"
label={t("Table action")}
/>
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label={t("Search tables")} placeholder={t("Search")} value={search} onChange={(event) => setSearch(event.target.value)} />
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? t("{count} of {total}", { count: displayedCount, total: data.length }) : displayedCount}</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />{t("Refresh")}</Button>
</>
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog tables</span>
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<span className="thot-label whitespace-nowrap">{t("Catalog tables")}</span>
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label={t("Search tables")} placeholder={t("Search")} value={search} onChange={(event) => setSearch(event.target.value)} />
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? t("{count} of {total}", { count: displayedCount, total: data.length }) : displayedCount}</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />{t("Refresh")}</Button>
</>
)
)}
</div>
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogTable>
<AgGridReact<CatalogTable> key={locale} {...gridLocaleProps}
ref={gridRef}
rowData={data}
columnDefs={columns}
@@ -797,7 +799,7 @@ export function DatabaseTables({
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No catalog tables. Synchronize the schema from the database view."
overlayNoRowsTemplate={t("No catalog tables. Synchronize the schema from the database view.")}
/>
</div>
</div>}
@@ -1,3 +1,4 @@
import { getLocale, useI18n, translate } from "../../i18n";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { LoaderCircle, LockOpen, Square } from "lucide-react";
@@ -32,7 +33,7 @@ const SAFE_FINAL_ERROR = "The run ended before all targets were processed. Revie
function statusLabel(run: DescriptionGenerationRun): string {
const label = run.status.replaceAll("_", " ");
return `${label[0].toUpperCase()}${label.slice(1)}`;
return translate(`${label[0].toUpperCase()}${label.slice(1)}`);
}
function isTerminal(run?: DescriptionGenerationRun): boolean {
@@ -59,7 +60,7 @@ function safeFinalError(summary: string | null): string | null {
}
function timestamp(value: string | null): string {
return value ? new Date(value).toLocaleString() : "Not available";
return value ? new Date(value).toLocaleString(getLocale()) : translate("Not available");
}
export function DescriptionGenerationDrawer({
@@ -71,6 +72,7 @@ export function DescriptionGenerationDrawer({
onRunUpdate,
onTerminal,
}: Props) {
const { t } = useI18n();
const queryClient = useQueryClient();
const runId = initialRun?.id ?? null;
const [events, setEvents] = useState<DescriptionGenerationEvent[]>([]);
@@ -105,7 +107,7 @@ export function DescriptionGenerationDrawer({
});
const visibleHistory = useMemo(
() => (historyQuery.data ?? []).filter((item) => !databaseId || item.databaseId === databaseId),
[databaseId, historyQuery.data],
[databaseId, historyQuery.data, t],
);
const latestEventSequence = events.at(-1)?.sequence ?? 0;
const {
@@ -133,7 +135,7 @@ export function DescriptionGenerationDrawer({
lastSequence.current = merged.at(-1)?.sequence ?? lastSequence.current;
return merged;
});
}, []);
}, [t]);
useEffect(() => {
setEvents([]);
@@ -257,7 +259,7 @@ export function DescriptionGenerationDrawer({
onRunUpdate(next);
await historyQuery.refetch();
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setAction(null);
}
@@ -273,7 +275,7 @@ export function DescriptionGenerationDrawer({
await historyQuery.refetch();
} catch (error) {
toast.error(error instanceof ApiError && error.status === 409
? "Unlock was refused because a live description worker is still running."
? t("Unlock was refused because a live description worker is still running.")
: apiErrorMessage(error));
} finally {
setAction(null);
@@ -285,26 +287,25 @@ export function DescriptionGenerationDrawer({
return (
<FleetLedgerDrawer
open
ariaLabel="Description generation"
eyebrow="Description generation"
title="Run history"
ariaLabel={t("Description generation")}
eyebrow={t("Description generation")}
title={t("Run history")}
onClose={onClose}
closeLabel="Close description generation"
closeLabel={t("Close description generation")}
busy={action !== null}
bodyClassName="thot-catalog-drawer__body--empty"
>
{historyQuery.isError ? (
<div role="alert" className="w-full rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(historyQuery.error)}
{t(apiErrorMessage(historyQuery.error))}
</div>
) : historyQuery.isLoading || (historyQuery.data?.length ?? 0) > 0 ? (
<p className="text-sm text-muted-foreground">Loading run history…</p>
<p className="text-sm text-muted-foreground">{t("Loading run history…")}</p>
) : (
<div className="w-full rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
<p className="font-semibold">No description generation runs yet.</p>
<p className="font-semibold">{t("No description generation runs yet.")}</p>
<p className="mt-1 text-muted-foreground">
Start a description generation to create the first history entry.
</p>
{t("Start a description generation to create the first history entry.")}</p>
</div>
)}
</FleetLedgerDrawer>
@@ -316,11 +317,11 @@ export function DescriptionGenerationDrawer({
? `${modelLabel} (${run.modelId})`
: run.modelId;
const progressCounters = [
["total", "Total"],
["processed", "Processed"],
["generated", "Generated"],
["nonGeneratable", "Not generated"],
["failed", "Failed"],
["total", t("Total")],
["processed", t("Processed")],
["generated", t("Generated")],
["nonGeneratable", t("Not generated")],
["failed", t("Failed")],
] as const;
const footer = ["queued", "running"].includes(run.status) ? (
<div className="w-full">
@@ -329,21 +330,20 @@ export function DescriptionGenerationDrawer({
id="description-generation-unlock-help"
className="mb-3 rounded-md border border-amber-500/35 bg-amber-500/8 px-3 py-2 text-xs leading-5 text-muted-foreground"
>
Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.
</p>
{t("Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.")}</p>
) : null}
<div className="flex flex-wrap justify-end gap-2">
{apparentlyStale ? (
<Button
type="button"
variant="outline"
aria-label="Unlock stale run"
aria-label={t("Unlock stale run")}
aria-describedby="description-generation-unlock-help"
disabled={action !== null}
onClick={() => void unlock()}
>
{action === "unlock" ? <LoaderCircle className="animate-spin" /> : <LockOpen />}
{action === "unlock" ? "Unlocking…" : "Unlock"}
{action === "unlock" ? t("Unlocking…") : t("Unlock")}
</Button>
) : null}
<Button
@@ -353,7 +353,7 @@ export function DescriptionGenerationDrawer({
onClick={() => void stop()}
>
{action === "stop" ? <LoaderCircle className="animate-spin" /> : <Square />}
{action === "stop" ? "Stopping…" : "Stop"}
{action === "stop" ? t("Stopping…") : t("Stop")}
</Button>
</div>
</div>
@@ -361,17 +361,17 @@ export function DescriptionGenerationDrawer({
return (
<FleetLedgerDrawer
open
ariaLabel="Description generation"
eyebrow="Description generation"
ariaLabel={t("Description generation")}
eyebrow={t("Description generation")}
title={(
<span className="inline-flex items-center gap-2">
{!isTerminal(run) ? <LoaderCircle className="size-5 animate-spin" aria-hidden="true" /> : null}
{statusLabel(run)}
{t(statusLabel(run))}
</span>
)}
description={run.scope.replaceAll("_", " ")}
description={t(run.scope.replaceAll("_", " "))}
onClose={onClose}
closeLabel="Close description generation"
closeLabel={t("Close description generation")}
busy={action !== null}
bodyClassName="thot-catalog-drawer__history-layout thot-catalog-drawer__run-layout"
footer={footer}
@@ -379,40 +379,40 @@ export function DescriptionGenerationDrawer({
<div className="thot-catalog-drawer__run-main">
{runQuery.isError ? (
<div role="alert" className="rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(runQuery.error)}
{t(apiErrorMessage(runQuery.error))}
</div>
) : null}
<div className={`grid gap-4 sm:grid-cols-2${runQuery.isError ? " mt-4" : ""}`}>
<section aria-label="AI usage">
<h3 className="thot-label mb-2">AI usage</h3>
<section aria-label={t("AI usage")}>
<h3 className="thot-label mb-2">{t("AI usage")}</h3>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt>Model</dt><dd className="truncate text-right" title={modelDisplay}>{modelDisplay}</dd>
<dt>Input tokens</dt><dd className="text-right tabular-nums">{(run.inputTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>Cache tokens</dt><dd className="text-right tabular-nums">{(run.cacheReadTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>Output tokens</dt><dd className="text-right tabular-nums">{(run.outputTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>{t("Model")}</dt><dd className="truncate text-right" title={modelDisplay}>{modelDisplay}</dd>
<dt>{t("Input tokens")}</dt><dd className="text-right tabular-nums">{(run.inputTokens ?? 0).toLocaleString(getLocale())}</dd>
<dt>{t("Cache tokens")}</dt><dd className="text-right tabular-nums">{(run.cacheReadTokens ?? 0).toLocaleString(getLocale())}</dd>
<dt>{t("Output tokens")}</dt><dd className="text-right tabular-nums">{(run.outputTokens ?? 0).toLocaleString(getLocale())}</dd>
</dl>
</section>
<section aria-label="Description generation timestamps">
<h3 className="thot-label mb-2">Timestamps</h3>
<section aria-label={t("Description generation timestamps")}>
<h3 className="thot-label mb-2">{t("Timestamps")}</h3>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt className="text-muted-foreground">Created</dt><dd className="text-right tabular-nums">{timestamp(run.createdAt)}</dd>
<dt className="text-muted-foreground">Started</dt><dd className="text-right tabular-nums">{timestamp(run.startedAt)}</dd>
<dt className="text-muted-foreground">Updated</dt><dd className="text-right tabular-nums">{timestamp(run.updatedAt)}</dd>
<dt className="text-muted-foreground">Finished</dt><dd className="text-right tabular-nums">{timestamp(run.finishedAt)}</dd>
<dt className="text-muted-foreground">{t("Created")}</dt><dd className="text-right tabular-nums">{timestamp(run.createdAt)}</dd>
<dt className="text-muted-foreground">{t("Started")}</dt><dd className="text-right tabular-nums">{timestamp(run.startedAt)}</dd>
<dt className="text-muted-foreground">{t("Updated")}</dt><dd className="text-right tabular-nums">{timestamp(run.updatedAt)}</dd>
<dt className="text-muted-foreground">{t("Finished")}</dt><dd className="text-right tabular-nums">{timestamp(run.finishedAt)}</dd>
</dl>
</section>
</div>
<section className="mt-4" aria-label="Description generation counters">
<h3 className="thot-label mb-2">Progress</h3>
<section className="mt-4" aria-label={t("Description generation counters")}>
<h3 className="thot-label mb-2">{t("Progress")}</h3>
<div className="overflow-x-auto rounded-md border border-border bg-muted/20">
<table className="w-full min-w-[28rem] table-fixed" aria-label="Description generation progress">
<table className="w-full min-w-[28rem] table-fixed" aria-label={t("Description generation progress")}>
<thead>
<tr className="border-b border-border bg-muted/45">
{progressCounters.map(([name, label]) => (
<th key={name} scope="col" className="border-l border-border px-2 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-muted-foreground first:border-l-0">
{label}
{t(label)}
</th>
))}
</tr>
@@ -432,18 +432,17 @@ export function DescriptionGenerationDrawer({
{finalError ? (
<div className="mt-3 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{finalError}
{t(finalError)}
</div>
) : null}
<section className="thot-catalog-drawer__events mt-3" aria-label="Description generation log">
<section className="thot-catalog-drawer__events mt-3" aria-label={t("Description generation log")}>
<div className="mb-2 flex items-center justify-between">
<h3 className="thot-label">Events</h3>
<h3 className="thot-label">{t("Events")}</h3>
<div className="flex items-center gap-2">
{!followingEventTail && events.length > 0 ? (
<Button type="button" variant="outline" size="xs" onClick={jumpToLatestEvent}>
Jump to latest
</Button>
{t("Jump to latest")}</Button>
) : null}
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
</div>
@@ -451,52 +450,52 @@ export function DescriptionGenerationDrawer({
<div
ref={eventLogRef}
role="log"
aria-label="Description generation events"
aria-label={t("Description generation events")}
aria-relevant="additions"
tabIndex={0}
onScroll={handleEventLogScroll}
className="thot-catalog-drawer__event-log overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
{events.length === 0 ? <p className="text-zinc-500">Waiting for events…</p> : events.map((event) => (
{events.length === 0 ? <p className="text-zinc-500">{t("Waiting for events…")}</p> : events.map((event) => (
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString()}</span>{event.message}
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString(getLocale())}</span>{t(event.message)}
</p>
))}
</div>
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{apiErrorMessage(eventQuery.error)}</p> : null}
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{t(apiErrorMessage(eventQuery.error))}</p> : null}
</section>
</div>
<section className="thot-catalog-drawer__history" aria-label="Description generation history">
<section className="thot-catalog-drawer__history" aria-label={t("Description generation history")}>
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="thot-label">Recent runs</h3>
<h3 className="thot-label">{t("Recent runs")}</h3>
<span className="text-xs tabular-nums text-muted-foreground">
{visibleHistory.length}
</span>
</div>
{historyQuery.isError ? (
<p role="alert" className="text-sm text-destructive">
{apiErrorMessage(historyQuery.error)}
{t(apiErrorMessage(historyQuery.error))}
</p>
) : historyQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading run history…</p>
<p className="text-sm text-muted-foreground">{t("Loading run history…")}</p>
) : visibleHistory.length === 0 ? (
<p className="text-sm text-muted-foreground">No description generation runs yet.</p>
<p className="text-sm text-muted-foreground">{t("No description generation runs yet.")}</p>
) : (
<div className="thot-catalog-drawer__history-list divide-y divide-border overflow-y-auto rounded-md border border-border">
{visibleHistory.map((item) => (
<button
key={item.id}
type="button"
aria-label={`${statusLabel(item)} · ${item.scope.replaceAll("_", " ")}`}
aria-label={`${statusLabel(item)} · ${t(item.scope.replaceAll("_", " "))}`}
aria-current={item.id === run.id ? "true" : undefined}
data-status={item.status}
className={`flex w-full items-start justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/25${item.status === "completed" ? " bg-[oklch(var(--success)/0.1)]" : ""}`}
onClick={() => onRunUpdate(item)}
>
<span className="font-medium">{statusLabel(item)}</span>
<span className="font-medium">{t(statusLabel(item))}</span>
<span className="shrink-0 text-right text-xs text-muted-foreground">
{item.processed}/{item.total}<br />{new Date(item.createdAt).toLocaleString()}
{item.processed}/{item.total}<br />{new Date(item.createdAt).toLocaleString(getLocale())}
</span>
</button>
))}
@@ -1,3 +1,4 @@
import { useI18n } from "../../i18n";
import {
useEffect,
useId,
@@ -50,6 +51,7 @@ export function FleetActionSelector<ActionId extends string>({
renderContext,
label = "Batch action",
}: FleetActionSelectorProps<ActionId>) {
const { t } = useI18n();
const [selectedActionId, setSelectedActionId] = useState<ActionId | "">("");
const hintId = useId();
const groups = useMemo(() => {
@@ -63,10 +65,10 @@ export function FleetActionSelector<ActionId extends string>({
grouped.get(action.group)!.push(action);
}
return order.map((group) => ({ group, actions: grouped.get(group)! }));
}, [actions]);
}, [actions, t]);
const selectedAction = actions.find((action) => action.id === selectedActionId);
const unavailableReason = selectedAction?.disabled
? selectedAction.disabledReason ?? "This action is not available for the current selection."
? selectedAction.disabledReason ?? t("This action is not available for the current selection.")
: null;
const actionScope = selectedAction?.scopeLabel;
@@ -85,33 +87,33 @@ export function FleetActionSelector<ActionId extends string>({
return (
<div className="thot-fleet-action-selector">
<div className="thot-fleet-action-selector__scope" aria-live="polite">
<strong>{actionScope ?? `${selectedCount} selected`}</strong>
<strong>{actionScope ? t(actionScope) : t("{count} selected", { count: selectedCount })}</strong>
{actionScope ? (
<span>Action scope</span>
<span>{t("Action scope")}</span>
) : hiddenSelectedCount > 0 ? (
<span>{hiddenSelectedCount} hidden by filter</span>
<span>{hiddenSelectedCount} {t("hidden by filter")}</span>
) : (
<span>Selected rows</span>
<span>{t("Selected rows")}</span>
)}
</div>
<label className="sr-only" htmlFor={hintId}>{label}</label>
<label className="sr-only" htmlFor={hintId}>{t(label)}</label>
<select
ref={selectRef}
id={hintId}
className="thot-fleet-action-selector__select"
aria-label={label}
aria-label={t(label)}
value={selectedActionId}
disabled={busy || selectedCount === 0}
onChange={(event) => setSelectedActionId(event.target.value as ActionId | "")}
>
<option value="">Choose an action…</option>
<option value="">{t("Choose an action…")}</option>
{groups.map(({ group, actions: groupActions }) => (
<optgroup key={group} label={group}>
<optgroup key={group} label={t(group)}>
{groupActions.map((action) => (
<option key={action.id} value={action.id}>
{action.label}
{t(action.label)}
{action.disabled && action.disabledReason
? `, unavailable: ${action.disabledReason}`
? t(", unavailable: {reason}", { reason: t(action.disabledReason) })
: ""}
</option>
))}
@@ -129,14 +131,12 @@ export function FleetActionSelector<ActionId extends string>({
onClick={() => void run()}
>
<Play aria-hidden="true" />
Run action
</Button>
{t("Run action")}</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onClear}>
<X aria-hidden="true" /> Clear
</Button>
<X aria-hidden="true" /> {t("Clear")}</Button>
{unavailableReason ? (
<span id={`${hintId}-reason`} className="thot-fleet-action-selector__reason" role="status">
{unavailableReason}
{t(unavailableReason)}
</span>
) : null}
</div>
@@ -11,7 +11,7 @@ test("uses operational body text for every available-command list", () => {
.flatMap((fileName) => {
const source = readFileSync(`${sourceDirectory}/${fileName}`, "utf8");
const matches = source.match(
/<select\b[\s\S]*?<option value="">Choose an action…<\/option>[\s\S]*?<\/select>/g,
/<select\b[\s\S]*?<option value="">\{t\("Choose an action…"\)\}<\/option>[\s\S]*?<\/select>/g,
);
return (matches ?? []).map((markup) => ({ fileName, markup }));
});

Some files were not shown because too many files have changed in this diff Show More