Compare commits

..
Author SHA1 Message Date
User c3caba94dd fix(ui): expand session dialogs and repeat confirmation actions
Publish documentation / publish (push) Successful in 24s
2026-09-14 18:13:53 +02:00
User b1723c34c4 docs: record server rollout of session and memory fixes
Publish documentation / publish (push) Successful in 32s
2026-09-14 17:25:23 +02:00
User d6cdffea62 fix: keep embedded session controls visible and handle empty memory
Publish documentation / publish (push) Successful in 34s
Cap the embedded shell at its portal container height so steering and stop controls remain accessible. Skip vector retrieval for an empty authoritative Memory archive and compute SQL-rule embeddings lazily.

Validated with 54 Memory tests, 90 frontend tests, five browser scenarios, frontend and Docker builds, and a read-only comparison against the real empty Memory archive.
2026-09-14 17:15:00 +02:00
Codex 49333a2d35 Merge full and embedded shell, administration UI and server handoff
Publish documentation / publish (push) Successful in 1m21s
2026-09-14 15:08:47 +02:00
Codex b006b94479 docs: prepare server Codex deployment handoff for ThothII and Omics 2026-09-14 15:08:46 +02:00
Codex bdcd8fcd28 fix(ui): open one session accordion panel at a time 2026-09-14 01:09:45 +02:00
Codex cf90c1bd51 fix(ui): collapse session lists and scope selection controls to panels 2026-09-13 18:12:27 +02:00
Codex 571a4bcaa2 fix(ui): show workspace readiness dot and bounded session accordions 2026-09-13 17:39:33 +02:00
Codex 9051463654 docs: document full and embedded rendering with server authentication 2026-09-13 17:28:10 +02:00
Codex 26c5605ff7 fix(ui): unify Memory and Evidence reading typography 2026-09-13 17:07:37 +02:00
Codex 3535fda958 fix(ui): improve knowledge reading and add isolated formatting examples 2026-09-13 16:52:53 +02:00
Codex 2953f6b608 fix(ui): unify Session navigation and restore uniform tab borders 2026-09-13 16:22:20 +02:00
Codex 45db3a239b fix(ui): simplify login and suppress pointer focus ring on locale select 2026-09-13 15:57:57 +02:00
Codex 7d826e46c0 fix(ui): match Omics header and compact workspace layout 2026-09-13 15:36:08 +02:00
Codex 648434a32e docs: record approved Omics GitHub to PSD relay handoff 2026-09-13 15:22:30 +02:00
Codex 023b822f83 Merge visual review into full shell and preserve bilingual layout 2026-09-13 14:55:58 +02:00
Codex d8a29bfbdd 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.
2026-09-13 14:26:39 +02:00
260 changed files with 11907 additions and 2449 deletions
+12 -2
View File
@@ -98,8 +98,18 @@ 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`.
- **Server deployment:** for the coordinated ThothII/Omics upgrade, follow
`docs/operations/server-codex-handoff.md`; it supersedes earlier Omics delivery
instructions. Omics source integration uses GitHub with no repository relay prerequisite.
- **Server identity:** for portal login/logout, proxy headers, or Omics deploy, read
`docs/install/authentication-upstream.md` before changing authentication. Omics
uses embedded/upstream, not a second ThothII OIDC login. Full/embedded rendering
is documented in `docs/architecture/application-shell.md`; release acceptance
is in `docs/testing/authentication-manual-acceptance.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.
+84 -3
View File
@@ -160,6 +160,15 @@ users can scan structure without adding nested containers.
## Colors
The full-mode application header matches Omics Portal's `--gsd-red-primary`
(`#CB333B`) in both themes. Its complete wordmark, including `II`, and controls
use a near-white foreground. This header is absent in embedded mode. The sidebar
and welcome wordmarks retain their red suffix. Context editing places workspace,
model and Done in one desktop row, stacking on narrow containers. Session-scope
tabs retain their selected fill and accessible keyboard state with a uniform one-pixel
border on every side, gray when inactive and red when active. Their padding is 11px
horizontal and 3px vertical, with a 38px minimum height and wrapping labels.
The palette combines warm porcelain surfaces, warm graphite text, and an instrument red used only
for action, focus, and important state. OKLCH values in the frontmatter are normative because the
frontend uses OKLCH tokens directly.
@@ -299,6 +308,27 @@ default, hover, focus, active, disabled, loading, and error behavior where those
### Navigation
- **Workspace readiness:** the Workspace navigation button carries an 8px dot to
the right of its label. Green means a selected workspace with confirmed ready
preprocessing and no query error; all other states are red. The button's
tooltip and accessible description retain the translated exact state. Do not
add a separate readiness text row or change the backend readiness gate.
- **Session groups:** one accessible single-open accordion contains Active sessions
and Archive, both initially closed. Below the scope tabs, show only their
adjacent section headers, without a redundant Sessions heading. Selection and
bulk-delete controls belong inside each panel and only appear for nonempty
lists. Select all affects that list only, preserves the other list's selection,
and exposes a mixed state for partial selection. Preserve the existing archived
flag as the grouping rule, independent of whether a Pi process is running.
Opening a section closes the other; either can be collapsed, including both.
Empty lists show only the translated "No sessions yet." message.
The open section uses the rail's remaining height; its list scrolls internally
with a cap of `min(18rem, 35dvh)`, while its trigger remains outside that scroll
area. The mobile navigation dialog supplies a bounded viewport-height container.
Keyboard users can focus and scroll each labelled panel.
- **Session entry:** one Session button returns to the current unfinished session,
including provisional creation, without resetting or reconnecting it. Otherwise
it prepares a new question using the normal readiness and unsaved-work guards.
- **Style:** compact session rows use `8px` corners and restrained vertical padding.
- **Default / Hover / Active:** porcelain at rest, Sunken Surface on hover, and a muted Navigation
Active red with a defined border when current. Exactly one top-level navigation control is current.
@@ -311,13 +341,31 @@ default, hover, focus, active, disabled, loading, and error behavior where those
width; a Navigation button opens the shared accessible dialog. Selecting another archive
page or pressing Escape closes it. Desktop retains the right session sidebar and its My sessions /
All sessions tabs. Core retains question/answer, eight phases, reviewer gates and the left log.
The portal owns the red header and left sidebar; ThothII must not duplicate them. Size to the
In embedded mode the portal owns the red header and left sidebar; ThothII must not duplicate them. Size to the
actual application container. Narrow session document panels may use the available width.
### Session review and confirmations
Session dialogs use the visible application area, including the portal's header
and side rail. Artifact and schema-column review can grow to 80rem wide and the
available height; short confirmations use up to 40rem and at least 18rem when
space permits. Keep a 24px outer margin on desktop and 8px on small or short
screens. Long review content scrolls internally; on very short screens the
whole dialog can also scroll so every action remains reachable.
Session forms and review gates repeat their existing primary confirmation above
and below the content, sharing selection, validation, pending state and response
handlers. Alternate-response inputs follow the same rule. Reserved navigation
controls remain below the review. Stop/delete initially focus Cancel; rename
initially focuses the name field. Administration dialogs and forms retain their
existing layout and actions.
### Tabs
- **Shape:** compact label tabs sit on a shared baseline with rounded top corners and a two-pixel
lower edge. Inactive labels retain a complete Quiet Border and Porcelain Card surface, so every
lower edge, except session-scope tabs which use a uniform one-pixel border, rounded
corners and a 4px gap without a shared border or negative bottom margin.
Inactive labels retain a Quiet Border and Porcelain Card surface, so every
label reads as a tab before interaction; hover feedback reinforces clickability.
- **Current:** the selected tab uses the muted Navigation Active red for its fill, text, and defined border.
It must expose `aria-selected`, participate in a labelled `tablist`/`tabpanel`, and be the only
@@ -337,6 +385,38 @@ default, hover, focus, active, disabled, loading, and error behavior where those
### Curated Evidence Documents
Memory and Evidence share the `thot-knowledge-reader` reading contract. Use locally
bundled Manrope with normal tracking for prose and labels, and these fixed roles:
- Card title: 24px, weight 600, line-height 1.3 (`thot-knowledge-title`).
- Field/section heading, including Scope and Provenance: 20px, weight 600,
line-height 1.4, 8px clearance below (`thot-knowledge-heading`).
- All narrative text, including scope, lists and provenance: 16px, weight 400,
line-height 1.65. Do not apply compact UI text sizes to these fields.
- Authored Markdown subheadings inside a field: 16px, weight 600, line-height 1.5,
24px above/8px below. They remain subordinate to the enclosing field heading;
their semantic heading levels and original content are preserved.
- Technical metadata labels/values: 14px/1.5, with weight 600 for labels.
Only code, paths and machine identifiers use the technical monospace family at
14px/1.65, identical for inline and fenced code (never compound `em` shrinkage).
Separate reading sections by 24px; keep the first Markdown block flush with its
field heading's 8px bottom gap. The same typography applies in light/dark and at
all responsive widths. Controls and archive indexes retain their compact UI roles.
Memory and Evidence detail readers use the entire available content width, without
the ordinary 72–75ch prose cap. This is the owner's explicit reading-layout choice.
Long unstructured paragraphs are split for display at existing sentence/semicolon
boundaries outside inline code and links; authored Markdown structure and stored
content are unchanged. Paragraph spacing is 1.25em. Scope and provenance share the
available width; provenance excerpts render Markdown rather than literal markers.
Copy actions use the two-overlapping-sheets icon, an accessible name/tooltip and
live success/failure feedback instead of a visible Copy label.
Memory has four explicitly FAKE formatting examples, one per family, in a separate
expandable section. They reuse the real detail reader but never enter persistence,
indexing, link search or model recall, and expose no edit/delete/save actions.
Curated evidence follows a fixed reading order: title, compact type and purpose summary, scope,
typed content, supporting excerpts, review items, then technical provenance. Curated v4 files
use short, visible YAML frontmatter for identity and classification. The Markdown title and
@@ -362,7 +442,8 @@ paths with copy controls, never browser file links to container-only locations.
- **Do** preserve information density with headings, rhythm, and progressive disclosure.
- **Do** keep keyboard focus explicit and pair color with text, shape, icon, or position.
- **Do** respect `prefers-reduced-motion` while preserving immediate non-kinetic feedback.
- **Do** use English for interface chrome and the workspace language for persisted document content.
- **Do** use the selected interface language (English by default) for chrome and preserve the
workspace language for persisted domain content. Session interaction language remains pinned.
- **Do** render curated metadata and scope as Markdown prose or lists, never as a frontmatter table.
- **Do** break long curated rules into paragraphs, labelled subsections, and lists at existing
punctuation boundaries while preserving the exact canonical text for machines.
+184 -16
View File
@@ -1,6 +1,6 @@
# ThothII — Project State
Last updated: 2026-09-12.
Last updated: 2026-09-14.
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,25 +12,192 @@ 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.
## Session review layout deployed — 2026-09-14
Session-only dialogs now use the visible app bounds: artifact/column review grows
up to 80rem wide and the available height; short confirmations grow to 40rem.
Existing primary actions appear above and below session forms and review content,
with shared handlers, validation and pending state. Stop/delete focus Cancel;
rename focuses the name field. Administration surfaces are unchanged. The activity
shortcut is relabelled To Administration / Vai all’Amministrazione, retaining its
workspace-management destination. 778 frontend tests, five responsive browser
scenarios, typecheck, translations and the production build passed. The owner
authorized deployment and the server frontend was recreated at 18:09 CEST with tag `b1723c34-session-dialogs-20260914`. Frontend is healthy,
Omics serves the new assets, and doctor passes 13/13 checks. Core and Omics web
were not restarted. See DESIGN.md for the layout contract and
`docs/reports/2026-09-14-session-dialogs-release.md` for provenance and rollback.
## Session composer and empty Memory fix deployed — 2026-09-14
The embedded shell now caps its height at the portal mount height, keeping steering
and Stop & save visible. Empty authoritative Memory archives return zero results
without requiring embedding/BM25; SQL-rule embedding is lazy and shared. The live
empty PSD archive reproduces 503 with the current image and succeeds with the
candidate. 54 Memory tests, 90 frontend tests, five browser scenarios and both image
builds passed. The owner authorized the restart and core/frontend were recreated on
2026-09-14 at 17:18 CEST with tags `49333a2d-session-memory-fix`, from code committed
as `d6cdffea`. Both are healthy; production Memory search returns `[]`, Omics serves
the corrected CSS, native doctor passes 13/13 checks, and admissions are reopened.
Session inventory is preserved. Backups and rollback images are retained. Native
CLI diagnostics must run as installation owner UID 10001 with access to Compose;
see `docs/reports/2026-09-14-session-layout-memory-fix.md` for exact commands and
the remaining interactive browser acceptance.
## 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 uses embedded with server-side
identity verification and a replaceable presentation-only PortalAdapter; this does
not mean this branch was verified on the production server. Its changes are
in Omics commit `95154e1`; production deployment remains pending.
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.
### Documentation handoff before branch closure — 2026-09-13
Current rendering architecture is in `docs/architecture/application-shell.md`;
the exact portal identity/proxy contract is in `docs/install/authentication-upstream.md`.
`docs/operations/server-codex-handoff.md` is the current server delivery/deploy
runbook, including Omics source integration from GitHub, configuration, tests and
rollback. It supersedes the earlier Omics repository-relay instructions. The acceptance matrix is
`docs/testing/authentication-manual-acceptance.md`. README, documentation navigation,
user/installation/authentication guides and descriptor examples point to these
paths. Local examples explicitly use full/en; the projected server example is
for standalone OIDC, not Omics upstream. No runtime configuration or deployment
was changed by that documentation pass. The 2026-09-14 delivery is prepared for
promotion to main; the actual merge is recorded in Git. PSD acceptance remains pending.
### Navigation readiness and session accordions — 2026-09-13
The Workspace navigation button now carries an accessible green/red readiness
dot instead of a separate text row. Green requires a selected workspace and a
successful, current `ready` response; checking, unavailable and other states are
red, with the state exposed through the tooltip and accessible description.
The backend readiness gate is unchanged.
Active sessions (non-archived) and Archive both start collapsed. Their adjacent
headers are the only visible content below the scope tabs: no Sessions heading
or external selection toolbar. Each nonempty panel owns its Select all and
bulk-delete controls, scoped to that list and preserving the other's selection.
As of 2026-09-14, only one section can be open at a time, and either can be
collapsed. Empty lists show only "No sessions yet." The open section uses the
remaining sidebar height, with scrolling content capped at `min(18rem, 35dvh)`. The mobile
navigation dialog also provides a bounded height. Keyboard controls and labels
are retained. See `DESIGN.md` and `docs/guida-utente.md` for the UI contract.
Verification: 768 frontend unit tests, 20 browser scenarios (including 80 mocked
sessions at 390/1280px), TypeScript and the production frontend build passed.
Only the Mac frontend was recreated; it is healthy at `127.0.0.1:8080`.
Core, catalog, Qdrant and embedding containers were not changed. The prior
frontend image is retained as
`thothii-frontend:before-single-session-accordion-20260914` for rollback.
### Current Omics delivery and server handoff — 2026-09-14
The owner corrected the delivery requirement: Omics is obtained from GitHub,
not relayed to another repository as part of this deployment. Any optional
server-side repository copy is solely the owner's separate concern. This
supersedes the 2026-09-13 relay agreement, including historical delivery notes
in the Omics branch. Do not make another remote publication a prerequisite.
GitHub branch `codex/thothii-embedded-shell` at
`https://github.com/Dallavilla-Tiziano/omics_portal.git` was reverified at
`fca10901a73666ca257d8f4cc4b77066295c400a` (functional commit `95154e1`).
The server operator integrates it with the current code in the confirmed Omics
checkout, normally `/home/chirone/omics_portal`, preserving later server changes.
`docs/operations/server-codex-handoff.md` is the authoritative ordered procedure:
inventory, source verification, native CLI and installation projections,
embedded/upstream auth, Omics templates/static assets/proxy, coordinated rollout,
acceptance and rollback. Server deployment and real IdP acceptance remain pending.
## Current product shape
### Isolated visual review, awaiting owner acceptance
### Shared Memory/Evidence typography — 2026-09-13
The UI revision is isolated in `/Users/mp/projects/ThothII-visual-review`, branch
`codex/ui-visual-review`, based on `2d1b714e`. The original checkout and its prototypes
remain intact. No merge to `main` has been performed.
Both detail readers now share Manrope and fixed reading roles: 24px card title,
20px section headings, 16px/1.65 narrative text, and 14px metadata/code. Authored
Markdown subheadings remain subordinate to field headings; inline/fenced code no
longer shrinks cumulatively. Full-width layout, paragraph separation and isolated
FAKE examples are retained. All 762 frontend tests and 18 browser scenarios pass,
including computed typography checks at 390/1280/2400px; typecheck, i18n and Docker
build pass. Only Mac frontend was recreated: `87fca0dc5e19`, image `19f1704b6bb2`,
healthy. Core and data services are unchanged. Rollback image:
`thothii-frontend:before-knowledge-typography-20260913`. See
`docs/reports/2026-09-13-knowledge-typography.md`. No PSD/Omics deployment.
Local Docker at `http://127.0.0.1:8080/` now runs the frontend image
`thothii-frontend:visual-review-20260912`. Only frontend was recreated; Core and all
data services/volumes/configurations are unchanged. The prior look is preserved as
`thothii-frontend:before-visual-review-20260912`. All five running services are healthy.
### Knowledge reading and isolated fake Memory examples — 2026-09-13
The revision uses bundled Manrope throughout, shared type roles, restrained semantic
colors, clearer Admin copy and a readable compact session-document panel. Core and
session behavior remain covered by the existing regression suite. Verification:
679 frontend tests, 7 Playwright visual/interaction scenarios and a production build.
See `docs/reports/2026-09-12-ui-visual-review-delivery.md` for scope, limits and the
exact local rollback command. Visual approval is required before adoption on `main`.
Memory/Evidence details now use all available width, with display-only paragraph
splitting for long plain prose and preserved code/Markdown/source data. Evidence
provenance renders Markdown; copy controls are accessible two-sheet icons.
Memory's separate Formatting examples section contains four FAKE cards (one per
family), automatically expanded for an empty archive. These client-side examples
cannot be edited/saved/indexed and are never submitted to the model or Memory API.
No real archive content was changed. 762 tests, 18 browser scenarios, typecheck,
i18n and Docker build pass. Only the Mac frontend was recreated: `fc4286b5406d`,
image `cbe0fe0dce55`, healthy; other services unchanged. Rollback image:
`thothii-frontend:before-knowledge-reading-20260913`. Details in
`docs/reports/2026-09-13-knowledge-reading.md`; no PSD/Omics deployment.
### Unified Session entry and complete tab borders — 2026-09-13
The sidebar has one Session/Sessione button: return to the current unfinished
session (including pending creation) without resetting/reconnecting it; otherwise
prepare a new question with existing readiness and dirty-edit guards. Creation
still requires submitting a question. A cold document panel is not a running session.
Session tabs now have 11px horizontal/3px vertical padding, at least 38px height,
and matching 1px borders on every side (gray inactive, red active), with no shared
baseline or overlapping bottom border. This supersedes the earlier border removal.
757 frontend tests, 15 browser scenarios, typecheck, i18n and Docker build pass.
Mac frontend `2844778d311c`, image `d58dccfee3f9`, is healthy; only that service
was recreated. Core/data services are unchanged, with no Omics or server deploy.
Rollback image: `thothii-frontend:before-session-navigation-20260913`.
### Login copy and language-selector focus — 2026-09-13
Removed the redundant login eyebrow/icon and installation-account explanation;
the main sign-in heading remains. The full-header language select no longer
shows an outer focus ring after pointer interaction; keyboard focus remains
visible, including after returning with Tab. EN/IT login and both themes are
covered by 36 targeted tests and 14 browser scenarios; typecheck/build pass.
Only the Mac frontend was rebuilt/recreated: `204efd40d3eb`, image `7dd0752ff823`,
healthy. Other containers are unchanged. Rollback image:
`thothii-frontend:before-login-focus-20260913`. No production deployment.
### Full-header and layout refinements — 2026-09-13
The owner's four visual adjustments are implemented: full header uses Omics
`#CB333B` in both themes with a light complete wordmark/controls; Database status
has 16px clearance below its top divider; workspace/model/Done share a compact
desktop row; session-scope tabs have no bottom border. Embedded has no extra header.
Verified with 71 targeted frontend tests, 11 browser scenarios, typecheck and
Docker production build. The Mac frontend was recreated alone and is healthy
(`8a1608ad7fc6`, image `a2f489ebe9de`); Core/data-service containers are unchanged.
Full/en is retained. Rollback image: `thothii-frontend:before-header-layout-20260913`.
See `docs/reports/2026-09-13-header-layout-refinements.md` for validation and rollback.
### Visual review integrated with the full/embedded shell
At the owner's request, the seven commits through `a59624a6` from
`codex/ui-visual-review` are integrated with the shell/i18n work in this checkout,
`codex/prototype-administration-pages`. Both branches started at `2d1b714e`; the
first full-shell build omitted that lateral branch and regressed the installed UI.
The integrated source retains bundled Manrope, shared type roles, catalog/context
alignment, wordmark sizes and composer autosizing alongside full/embedded and EN/IT.
Local Docker was updated at 12:54 UTC on 2026-09-13: frontend image `605a6e6bba68`,
healthy; Core and all data-service containers were unchanged. Mac remains full/en.
The immediate pre-merge frontend is retained as
`thothii-frontend:before-visual-shell-merge-20260913`.
Verification: 755 frontend tests, 11 Playwright visual/interaction scenarios at five
widths, translation-catalog checks, typecheck and production build. See
`docs/reports/2026-09-13-visual-shell-integration.md` for delivery, provenance and
rollback details. The separate visual-review worktree and its rollback images are
retained. No merge to `main`, push or production deployment is part of this delivery.
Gitea #28–#31 follow-up is implemented in this checkout: Workspace's four tabs,
Database list-first entry without the preparation footer, full-height Pi instructions
@@ -274,7 +441,8 @@ schema, Evidence, and vector mutation commands are retired.
The right Administration sidebar invokes that same operation for the selected workspace. It shows
only current readiness or the latest bounded failure diagnostic; there is no preprocessing history.
Known non-ready state disables **New session**, while backend admission remains authoritative.
Known non-ready state disables **Session** when it would start a new question,
while backend admission remains authoritative.
The same control exposes an inline-confirmed **Clear** action to remove replaceable reference
vectors, LSH, corpus, and checkpoints while preserving the separate Memory collection. The host CLI
equivalent is `workspace preprocess clear`.
+16 -2
View File
@@ -4,8 +4,22 @@ ThothII is a human-reviewed NL-to-SQL workflow with a React frontend and a Fasti
core. The portable deployment runs two application services plus the installation-local metadata
catalog; DWH and LLM services remain external. Semantic services are bundled in Compose.
Authentication is configured through the single host CLI tht: see the [local authentication guide](docs/install/authentication-local.md),
[generic OIDC guide](docs/install/authentication-oidc.md), and [manual acceptance matrix](docs/testing/authentication-manual-acceptance.md).
The same frontend supports **full** (its own header) and **embedded** (inside a
portal). This choice is independent of authentication: the Mac uses full/local,
Omics uses embedded/upstream with its existing login, and a standalone server
can use full/OIDC. See [rendering architecture](docs/architecture/application-shell.md)
and [configuration, Omics delivery and deploy](docs/operations/shell-and-localization.md).
For the current server upgrade with Omics Portal, follow the ordered
[Codex server handoff](docs/operations/server-codex-handoff.md), including source
integration, embedded/upstream configuration, coordinated rollout and rollback.
Local/OIDC authentication is configured through the host CLI `tht`; portal
authentication is established by the trusted server proxy. See the
[local guide](docs/install/authentication-local.md),
[OIDC guide](docs/install/authentication-oidc.md),
[upstream integration](docs/install/authentication-upstream.md), and
[manual acceptance matrix](docs/testing/authentication-manual-acceptance.md).
## Docker Compose: local startup
+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" });
@@ -0,0 +1,5 @@
# Local-only rollback to the full-shell frontend before visual integration.
# Apply after local installation profiles, with --no-build and --no-deps.
services:
frontend:
image: thothii-frontend:before-visual-shell-merge-20260913
@@ -2,6 +2,10 @@
# Replace every absolute path before using this as an advanced reference.
schemaVersion: 2
profile: local
# Mac standalone example; the Omics server requires embedded/upstream separately.
shell:
mode: full
defaultLocale: en
projectDirectory: "<abs>/projects/ThothII"
envFile: "<abs>/projects/ThothII/deploy/psd/operator.env"
workspaceRepository:
+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.
+130
View File
@@ -0,0 +1,130 @@
# Rendering full ed embedded
ThothII ha una sola applicazione React, una sola build Vite e gli stessi servizi
backend. «Doppio rendering» significa due modi di ospitare quella applicazione,
non due versioni delle pagine e non rendering React sul server. Django renderizza
il contenitore Omics; React renderizza ThothII nel browser, dentro `#root`.
## Tre decisioni indipendenti
| Decisione | Configurazione | Effetto |
| --- | --- | --- |
| Distribuzione | `profile: local` oppure `server` | Compose, percorsi e vincoli operativi |
| Presentazione | `shell.mode: full` oppure `embedded` | Proprietario di header e preferenze |
| Autenticazione | `auth.yaml` local/OIDC oppure `AUTH_MODE=upstream` | Chi verifica l'identità, come arriva al backend |
Il Mac usa **full + local**, con lingua iniziale inglese. L'integrazione Omics
usa **embedded + upstream**, con accesso già verificato dal portale. Un server
autonomo può usare **full + oidc**. Cambiare `shell.mode` non abilita un metodo
di autenticazione e non modifica permessi o proprietari delle sessioni.
Full con upstream può visualizzare un'identità già verificata dal proxy, ma non
ha un logout ThothII disponibile: non è il profilo autonomo con login/logout.
Embedded non avvia login locale o OIDC anche se il backend è configurato così;
questa combinazione non realizza il login unico Omics e non va usata come fallback.
## Composizione comune
```mermaid
flowchart TD
CONFIG["config.js pubblico"] --> SHELL["ShellProvider"]
FULL["Preferenze full nel browser"] --> SHELL
HOST["Documento Omics"] --> ADAPTER["OmicsPortalAdapter: solo presentazione"]
ADAPTER --> SHELL
SHELL --> GATE["AuthGate: verifica GET /me"]
GATE --> APP["AppShell: stesse pagine, sessioni e amministrazione"]
AUTH["Backend: cookie locale/OIDC o identità upstream"] --> GATE
```
`ShellProvider` risolve la configurazione, applica lingua/tema e monta i contenuti
solo dopo uno snapshot host valido in embedded. `AuthGate` verifica l'accesso;
`AppShell` e le pagine non devono leggere selettori o eventi specifici di Omics.
Il cambio utente smonta lo stato applicativo della precedente identità.
## Full
- Header ThothII rosso Omics `#CB333B` in entrambi i temi; logo interamente chiaro.
- Selettore EN/IT, tema light/dark, fullscreen e nome verificato dell'utente.
- Menu del nome con logout soltanto per local/OIDC; nessuna rotellina admin.
L'amministrazione resta nella navigazione applicativa, secondo i permessi.
- Margine sinistro vuoto e simmetrico al destro: `max(20px, 1.5rem)`, normalmente
24px con radice a 16px. Non è una seconda sidebar di navigazione.
- Lingua e tema ricordati sullo stesso origin in `localStorage`, nelle chiavi
`thothii:shell:locale` e `thothii:shell:theme`. Non sono preferenze server per
utente. In assenza di preferenze: `defaultLocale` e tema light.
- Fullscreen usa `document.documentElement.requestFullscreen()` e
`document.exitFullscreen()`: nasconde il contorno del browser dove supportato.
L'icona cambia sullo stato reale, anche dopo Esc; un rifiuto mostra un errore.
Non è un semplice ingrandimento CSS e non scatta automaticamente all'accesso.
## Embedded
- Nessun header ThothII, selettore lingua, toggle tema, login o logout autonomo.
I controlli rimangono nell'header generale Omics.
- React è nello stesso documento della pagina `/kokoro/datamart-builder/`, non
in un iframe. Non serve `postMessage` né un secondo protocollo di sessione.
- L'adapter legge la lingua Django già confermata, osserva il tema del documento
e ascolta il fullscreen reale. Le azioni rimangono di proprietà del portale.
- Un contesto Omics mancante o invalido mostra un errore d'integrazione; non
passa silenziosamente a full e non offre un secondo login.
- Il portale assegna l'altezza disponibile sotto il proprio header: catena flex
con `min-height: 0`, root contenuto e altezza applicativa vincolata al contenitore.
Il contratto ThothII espone `--thoth-app-height` (fallback `100dvh`); verificare
il contenitore reale, non presumere che l'intera viewport appartenga a React.
Il template Omics mantiene inoltre i suoi override di compatibilità.
Il reset CSS è limitato al mount React e ai popup dell'applicazione, senza
richiedere CSS `@scope`. I token e i popup seguono il tema applicativo. Questo
non rende indipendenti fogli di stile arbitrari caricati dal portale: la verifica
del documento condiviso rimane necessaria a ogni integrazione.
## Caricamento e configurazione pubblica
Il descrittore installato è la sorgente di verità. Il CLI genera
`generated/frontend/config.js` e il suo mount di sola lettura nella proiezione
`generated/compose.models.yaml`. Il file pubblico contiene solo `backendBaseUrl`
e `shell`, mai identità, token, password o percorsi host. Va caricato **prima** del
modulo React e servito senza cache. Nessuna build separata è richiesta per
cambiare modalità; occorre rigenerare e applicare i mount tramite il lifecycle.
L'ordine Omics è: config pubblico → override del solo prefisso API → asset dal
manifest Vite. L'override deve conservare `shell`; l'adapter non configura il proxy.
Il default completo di shell omessa è embedded/en/omics-portal. Il CLI normalizza
anche singoli campi omessi; un oggetto `shell` scritto manualmente nel browser
deve invece contenere `mode` e `defaultLocale`, altrimenti viene rifiutato.
## Lingua, continuità e dati
La lingua UI traduce il testo dell'applicazione, non i contenuti di dominio.
Alla creazione, la lingua UI viene acquisita come `interactionLanguage`; il
manifest salva `interaction_language`, che governa domande e scelte del modello.
Alla ripresa vale la lingua salvata, non l'ultima scelta dell'header. Per i manifest
precedenti senza campo viene fissata la lingua workspace disponibile alla prima ripresa.
Il cambio lingua Omics invia il form Django e ricarica la pagina. ThothII conserva
solo l'ID della selezione in `sessionStorage`, separato per pathname, issuer e
subject. Riapre i documenti, non avvia una generazione. Bozze non inviate e modifiche
non salvate richiedono conferma prima della navigazione; non sono una trascrizione
salvata. La ripresa operativa resta esplicita.
## Punti di implementazione e manutenzione
| Sorgente | Responsabilità |
| --- | --- |
| `tools/tht/internal/config/shell.go` | Normalizzazione e validazione del descrittore |
| `tools/tht/internal/modelprojection/projection.go` | Config pubblico e mount generati |
| `frontend/src/api/runtime-config.ts` | Validazione browser e prefisso API same-origin |
| `frontend/src/shell/host/ShellProvider.tsx` | Composizione, preferenze e tema |
| `frontend/src/shell/host/FullHeader.tsx` | Controlli solo full |
| `frontend/src/shell/host/OmicsPortalAdapter.ts` | Conoscenza del documento Omics |
| `frontend/src/auth/AuthGate.tsx` | Accesso e ricontrolli al ritorno alla pagina |
| `backend/src/auth/auth.ts` e `principal.ts` | Verifica server dell'identità |
Per un altro portale servono un'implementazione del
[PortalAdapter](../contracts/portal-shell-adapter-v1.md), la sua registrazione nei
validatori CLI/browser e nel punto di composizione, oltre al
[contratto di autenticazione server](../install/authentication-upstream.md).
Il nome di una classe non è un plugin caricabile dinamicamente da YAML.
Procedure: [configurazione e deploy](../operations/shell-and-localization.md),
[autenticazione](authentication.md), [accettazione](../testing/authentication-manual-acceptance.md).
+44 -5
View File
@@ -1,18 +1,27 @@
# Authentication architecture
ThothII has two production authentication modes: `local` and generic `oidc`. The host operator
surface is one CLI, `tht`; there is no separate authentication executable. The backend owns
opaque browser sessions and authorization, while `tht` owns protected configuration and local-user
files.
ThothII supports `local` and generic `oidc` through protected `auth.yaml`, plus
the trusted-proxy `upstream` path used by Omics. The host operator surface is
one CLI, `tht`; there is no separate authentication executable. The backend owns
authorization in all paths and opaque browser sessions only in local/OIDC.
`tht` owns protected local/OIDC configuration and local-user files; upstream
identity is supplied per request by the authenticated server proxy.
Presentation is separate: [full/embedded rendering](application-shell.md) does
not select authentication. The Mac uses full/local; Omics uses embedded/upstream;
a standalone server can use full/OIDC. Do not configure a second ThothII OIDC
login simply because Omics itself authenticates users through Authentik.
```mermaid
flowchart TB
BROWSER["Browser"] --> BOUNDARY["Authentication boundary"]
BOUNDARY --> LOCAL["Local users\nArgon2id hashes"]
BOUNDARY --> OIDC["OIDC provider\nAuthorization Code PKCE"]
BOUNDARY --> UPSTREAM["Trusted proxy\nVerified portal session"]
OIDC --> GROUPS["Groups claim\nexact mapping"]
LOCAL --> PRINCIPAL["Thoth principal"]
GROUPS --> PRINCIPAL
UPSTREAM --> PRINCIPAL
PRINCIPAL --> ROLES["Roles"]
ROLES --> PERMISSIONS["Permissions"]
PERMISSIONS --> ROUTES["Protected routes"]
@@ -21,6 +30,13 @@ flowchart TB
## Configuration and trust boundaries
The following protected-file configuration applies to local/OIDC. Upstream uses
`AUTH_MODE=upstream` without a mounted `auth.yaml` or authentication runtime
projection. The backend refuses both authorities together. `AUTH_MODE=none`
and `mock` are development/test modes, not production fallbacks. The exact
upstream setup, header contract, proxy hops and origin checks are in the
[server integration guide](../install/authentication-upstream.md).
The installation descriptor points to an operator-controlled authentication directory. It contains
non-secret `auth.yaml` and, for local mode, `users.yaml`. POSIX installations use a private
directory and owner-only regular files; Windows uses equivalent owner-only ACLs. Secret values are
@@ -49,6 +65,10 @@ Authentik is the first certified group-catalog adapter, not a special browser lo
## Group authorization
This section describes **ThothII's direct OIDC login**, not the embedded Omics
path. Omics checks its own capability and administrator status and supplies
normalized identity headers; ThothII does not repeat the OIDC groups exchange.
OIDC must return a direct, non-empty `groups` claim whose value is a JSON array of strings.
Missing, malformed, indirect, or overage-style claims fail closed. The browser callback returns
HTTP 401 with the generic code `oidc_callback_failed`; it does not expose the internal reason.
@@ -100,6 +120,10 @@ prerequisite fails.
## Browser sessions
This section applies only to **local and direct OIDC**. Upstream reuses the
portal's authenticated session at the proxy boundary, not a ThothII cookie;
its `/me` response has `session: null` and `csrfToken: null`.
The browser receives only an opaque `HttpOnly`, `SameSite=Lax` cookie named `thothii_session`.
State-changing cookie requests require the in-memory CSRF token, same-origin `Origin`, and Fetch
Metadata checks when present. The frontend never stores bearer tokens or session secrets in Web
@@ -116,5 +140,20 @@ affected sessions. Authentication configuration revision changes invalidate all
reload. Logout deletes the server record. Backup restore excludes active sessions and OIDC state,
recreates empty private auth-state directories, and therefore forces reauthentication.
Full local/OIDC logout calls `POST /auth/logout`, revokes the server session and
clears its cookie. It does not call the identity provider's global logout. If the
provider still has an SSO session, the next OIDC login can complete without
another password prompt. Embedded has no ThothII logout control: use the portal.
## Access revalidation
The frontend treats `/me` as the access authority. In embedded it does not fetch
`/auth/config` or offer local/OIDC login. On focus, pageshow, visibility return
and event-stream reconnection it rechecks access. A 401/403 from this probe clears
protected state; a 403 on one operation is not automatically an app-wide logout.
Neither the DOM adapter nor the proxy's initial SSE check guarantees instantaneous
revocation of streams already open in other tabs.
See the [local guide](../install/authentication-local.md), [generic OIDC guide](../install/authentication-oidc.md),
and [Authentik guide](../install/authentik.md) for operator procedures.
[Authentik guide](../install/authentik.md), [upstream integration](../install/authentication-upstream.md),
and [manual acceptance matrix](../testing/authentication-manual-acceptance.md).
+8
View File
@@ -44,6 +44,14 @@ Dipendenze principali:
## Session sequence
The shared frontend is wrapped by `ShellProvider` (full preferences or a
replaceable portal presentation adapter), then `AuthGate` (backend identity),
then `AppShell`. Omics-specific DOM details belong only to `OmicsPortalAdapter`;
credentials and principal validation belong to the server, never that adapter.
Full/embedded do not duplicate the session workflow below. See
[rendering architecture](application-shell.md) and
[upstream identity](../install/authentication-upstream.md) for both boundaries.
The main path starts with a user question and ends with an SSE event. Reviewer decisions use the same channel and are persisted by the harness.
```mermaid
+8 -3
View File
@@ -4,8 +4,11 @@
ThothII is a **human-in-the-loop datamart builder**. It turns a natural-language question into validated SQL, and optionally a dbt datamart, through a **deterministic eight-phase NL-to-SQL workflow** in which the model *proposes* and a human reviewer *decides* at gates.
Production authentication uses local authentication or generic OIDC. `tht` is the only operator CLI.
For sessions, roles, groups, diagnostics, and recovery, see the [authentication documentation](authentication.md).
Production authentication uses local authentication, generic OIDC, or the
trusted-proxy upstream path used by Omics. `tht` is the operator CLI for the
installation and local/OIDC configuration. For roles and recovery, see
[authentication](authentication.md). One React build supports full and embedded;
[rendering architecture](application-shell.md) separates presentation from identity.
```mermaid
flowchart LR
@@ -101,7 +104,9 @@ the core can admit a new session.
- `tht -c`/`--config` is a **per-command** option. It must follow the subcommand, never precede it (`ThtRunner.buildArgv` enforces this).
- `--json` output must be plain JSON on stdout. It is a machine-readable contract.
- UI strings are in English. Document *content* stays in the workspace language because it is the actual data; only chrome and labels are in English.
- UI strings support English and Italian, with English fallback. Session interaction language
is pinned at creation; document content remains in the workspace language. See
[shell and localization](../operations/shell-and-localization.md).
- Each workspace defines identity and optional Evidence only. The PostgreSQL Metadata Catalog defines
its DWH target and binding; secrets remain in the protected workspace secret store.
- Settings are global (`backend/data/settings.json`: workspace/thinking); provider/model choices are
+135
View File
@@ -0,0 +1,135 @@
# 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.
Questi default sono normalizzati dal CLI prima della proiezione. Nel browser,
shell interamente omessa ha gli stessi default, ma un oggetto `shell` parziale
senza `mode` o `defaultLocale` viene rifiutato: non scrivere proiezioni a mano.
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 |
Il template Omics aggiornato allinea anche `html lang` alla lingua Django, ma
la fonte dell'adapter rimane `select.omics-language-select[data-lang]`. Leggere
il valore renderizzato evita di anticipare un cambio lingua prima che il form
abbia successo. Cambiare soltanto `select.value` o `data-lang` senza il normale
reload non è un trasporto runtime implementato per la lingua.
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 | ThothII local/OIDC; upstream non offre logout locale | 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 [contratto upstream](../install/authentication-upstream.md) specifica header,
origine, rete e configurazioni incompatibili. Lo snapshot non può contenere
`authenticated`, utente, ruoli, cookie o token; un evento browser non autorizza
una richiesta API. Il prefisso API viene configurato separatamente prima del
caricamento React, non viene dedotto dall'adapter.
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. Oggi `ShellProvider` istanzia direttamente `OmicsPortalAdapter`:
per sostituirlo aggiornare quel punto e i nomi accettati in
`tools/tht/internal/config/shell.go` e `frontend/src/api/runtime-config.ts`.
Non è disponibile il caricamento dinamico di classi da una stringa YAML.
Non occorre implementare ora iframe o un secondo portale.
La nuova implementazione deve pubblicare uno snapshot iniziale completo, poi gli
aggiornamenti; segnalare contesto invalido; liberare tutti i listener alla
disiscrizione. Locale ben formato ma non tradotto significa fallback inglese;
locale assente/malformato e tema diverso da light/dark sono errori di integrazione.
Non cambiare componenti applicativi o workflow per aggiungere selettori specifici
del nuovo 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.
Vedere anche [architettura del rendering](../architecture/application-shell.md)
e [matrice di accettazione](../testing/authentication-manual-acceptance.md).
+39 -3
View File
@@ -4,10 +4,30 @@ This guide is for a reviewer using a configured ThothII installation. Installati
publication, preprocessing, and database administration are separate paths; links to them are at
the end of this page.
## Standalone or inside Omics
In **full** mode, ThothII has its own red header. Sign in using the installation's
local account or the configured identity provider. The header lets you select
English/Italian, light/dark, and fullscreen; Esc exits fullscreen. Open the user
name menu to log out of ThothII. OIDC logout does not necessarily log out other
applications using the same provider.
In **embedded** mode, first sign in to Omics and choose **Datamart Builder** in
its left menu. ThothII opens with that authenticated identity: there is no second
login or duplicate header. Use Omics's language, theme, fullscreen and logout
controls. If portal access expires, return to Omics, sign in and reopen the page.
The Mac starts in English unless the browser remembers another choice. Changing
the UI language affects labels, not saved domain content. A new session takes
the selected language for the model's questions and reviewer choices; an existing
session retains its saved language when resumed. Omics's language change reloads
the page: confirm or cancel any unsaved-work warning. Reopening the saved session
selection shows documents; it does not automatically restart generation.
## Before creating a session
An administrator must have selected a workspace and configured the installation-wide provider,
model, and thinking settings. The New session form deliberately asks only for the question.
model, and thinking settings. The new-question form deliberately asks only for the question.
The workspace is a pinned Git revision. A subsequent workspace update cannot alter a session
already created from an earlier revision. If a workspace cannot reach its configured runtime DWH,
@@ -15,7 +35,9 @@ new sessions are refused before any session state is written.
## Create and review a session
1. Sign in and select **New session**.
1. Sign in and select **Session** (**Sessione** in Italian). If a session is already
open and unfinished, this returns to it without restarting it. Otherwise it
opens a new question; no session is created until you submit that question.
2. Enter a precise business question, including the relevant time period and desired output. For
example: “List patients discharged in the last 30 days, with ward and discharge date.”
3. Review each gate and make the decision requested by the widget. A choice with a decision payload
@@ -37,6 +59,19 @@ The workflow phases are fixed:
## Resume, archive, and the meaning of saved state
In Administration, the dot beside **Workspace** is green when readiness is
confirmed and red otherwise. Hover the button for the exact state; assistive
technology receives the same description. Select Workspace to inspect preparation.
The session sidebar has two accordion sections: **Active sessions** and
**Archive**, both initially closed. Only their headers appear below the scope tabs.
Inside each nonempty list, **Select all** selects only that list; its delete action
also applies only to the selected sessions in that list. The other list's selection
is preserved. Opening a section closes the other; clicking the open section closes
it too. Empty lists show only "No sessions yet." Long lists scroll inside
their own panels. Here active means not archived, not necessarily a running model
process. Existing groups and session actions remain inside those sections.
The sidebar lists sessions and their current lifecycle. Resuming returns to the last incomplete
phase. A finalized or archived session cannot be resumed.
@@ -54,4 +89,5 @@ happen from the workflow’s point of view.
- To author material the workflow can retrieve, use [Evidence](evidence.md). A proposal from a
session does not become Evidence automatically: a curator must review and publish it in Git.
- For login and access recovery, use [local authentication](install/authentication-local.md) or
[OIDC authentication](install/authentication-oidc.md).
[OIDC authentication](install/authentication-oidc.md) for full, or contact the
portal administrator for [embedded/upstream access](install/authentication-upstream.md).
+5
View File
@@ -8,6 +8,11 @@ Start with the path that matches the work you need to do:
| I need to… | Start here |
| --- | --- |
| Install or operate one instance | [Install and first start](install/first-start.md) |
| Upgrade the server and integrate Omics Portal | [Codex server handoff](operations/server-codex-handoff.md) |
| Choose full/embedded and configure the shell | [Shell and localization](operations/shell-and-localization.md) |
| Reuse an authenticated server portal without a second login | [Upstream authentication](install/authentication-upstream.md) |
| Understand how the two renderings share the same application | [Rendering architecture](architecture/application-shell.md) |
| Validate login, logout and portal integration before release | [Acceptance matrix](testing/authentication-manual-acceptance.md) |
| Add, update, or prepare a workspace | [Workspace operations](operations/workspaces.md) |
| Ask a question and review the SQL workflow | [User guide](guida-utente.md) |
| Configure and refresh an authoritative database catalog | [Database management](operations/database-management.md) |
+11 -1
View File
@@ -1,6 +1,11 @@
# Local authentication
Use local mode for a standalone PC or Mac. Configure it through `tht`; passwords are entered at an
Use local mode for a standalone PC or Mac, with `shell.mode: full` and
`shell.defaultLocale: en` in the installation descriptor. Presentation and
authentication are independent: selecting full does not create accounts. Omics
embedded instead uses the [upstream guide](authentication-upstream.md), not local users.
Configure local authentication through `tht`; passwords are entered at an
echo-free prompt or read from a protected `--password-file`, never from a command argument.
## Bootstrap
@@ -56,6 +61,11 @@ machine use; JSON output is pristine on stdout.
## Session behavior and recovery
Full shows its own login form and, after login, the verified display name in its
header. The name menu contains Log out. This sends a CSRF-protected request to
`/api/auth/logout`, revokes the session and returns to login. Language/theme
preferences may remain in the browser; they are not credentials.
An ordinary login expires after 2 hours idle or 12 hours absolute. Selecting **Remember me** makes
the cookie persistent and changes the limits to 7 days idle or 30 days absolute. Remembered
sessions survive a browser and backend restart, but not a user revision change, configuration
+23
View File
@@ -1,10 +1,20 @@
# Generic OIDC authentication
Use this guide for **ThothII's own login**, normally `shell.mode: full` on an
autonomous server. It is not the integration procedure for an already logged-in
Omics user. That deployment uses [embedded/upstream](authentication-upstream.md),
even when Omics's identity provider is Authentik.
OIDC mode supports a standards-based provider. The browser flow is generic: Authorization Code,
PKCE S256, state, nonce, issuer/signature/audience/expiry validation, and the fixed callback
`<publicUrl>/api/auth/oidc/callback`. The browser and API must use the same origin; configure the
reverse proxy to preserve that public origin and callback path.
`publicUrl` is the public origin, without an application subpath. The current
full OIDC browser entry and callback use `/api/auth/oidc/login` and
`/api/auth/oidc/callback`; arbitrary prefixed OIDC hosting is not implemented by
selecting a different `backendBaseUrl`.
Configure the installation with `tht`:
```sh
@@ -18,6 +28,9 @@ The OIDC client secret is supplied through the protected secret bundle under the
`THT_OIDC_CLIENT_SECRET`; it is never written into `auth.yaml`. The default scopes are exactly
`openid`, `profile`, and `email`.
Keep `AUTH_MODE` unset when using this file. A simultaneously mounted local/OIDC
configuration and `AUTH_MODE=upstream` is an error, not a fallback chain.
The non-secret OIDC configuration has this exact shape (replace angle-bracket placeholders with
operator values):
@@ -92,3 +105,13 @@ relevant diagnostic surface.
The complete closed diagnostic-code union and exact role-to-permission expansion are in the
[authentication architecture](../architecture/authentication.md).
## Browser login and logout
ThothII redirects the browser to the provider and creates its own opaque session
after validating the callback. An existing provider SSO session may avoid another
password prompt, but this remains a distinct ThothII login/session, unlike Omics
upstream. Full's name menu logs out of ThothII only. It does not revoke the
provider session or log out other applications, so a subsequent login can return
immediately through SSO. No provider token is placed in the UI adapter or browser
storage. See the [manual acceptance matrix](../testing/authentication-manual-acceptance.md).
+186
View File
@@ -0,0 +1,186 @@
# Autenticazione tramite portale e proxy fidato
Questa è la modalità **upstream** usata dall'integrazione Omics. Non è il login
OIDC diretto di ThothII: l'utente accede a Omics come già fa, poi sceglie
Datamart Builder e trova ThothII già autenticato. Non deve essere creato un utente
locale ThothII né effettuato un secondo scambio OIDC dall'applicazione embedded.
## Il confine di fiducia
```mermaid
sequenceDiagram
actor U as Utente già autenticato
participant N as Nginx Omics
participant D as Django Omics
participant T as Core ThothII upstream
U->>D: Apri Datamart Builder
D-->>U: Pagina autorizzata con mount React
U->>N: GET /datamart-builder/api/me (cookie Omics)
N->>D: Subrequest interna /datamart-builder/api-auth
D-->>N: 200 + identità verificata, oppure 403
N->>T: GET /me + intestazioni normalizzate (solo se autorizzato)
T-->>U: Identità e permessi applicativi, oppure rifiuto
```
L'header e l'adapter JavaScript non autenticano nessuno. Il backend accetta una
richiesta upstream solo con un'identità valida ricevuta da un percorso di rete
fidato. Gli header non sono firmati da ThothII: la protezione è il proxy che
verifica la sessione e sovrascrive l'identità, insieme all'isolamento del core.
Un core upstream direttamente raggiungibile da client non fidati è una falla,
non una modalità alternativa di accesso.
## Configurazione del core
Per Omics il descrittore pubblico deve contenere:
```yaml
shell:
mode: embedded
defaultLocale: en
adapter: omics-portal
```
Separatamente, il **processo core** deve ricevere `AUTH_MODE=upstream`. Definirlo
nell'override Compose approvato e incluso nell'installazione; una variabile nel
file di interpolazione `.env` non viene passata automaticamente al container:
```yaml
services:
core:
environment:
AUTH_MODE: upstream
```
È solo il frammento di selezione auth, non un file Compose completo né una
configurazione di rete sufficiente. Non aggiunge porte pubbliche.
Condizioni obbligatorie:
1. Nessun `auth.yaml` local/OIDC deve essere effettivamente montato al percorso
letto dal core (default `/run/thothii-auth/auth.yaml`). Se è presente insieme
ad `AUTH_MODE`, l'avvio fallisce. Non impostare `AUTH_MODE=local` o `oidc`:
questi due modi si selezionano dal file, non da quella variabile.
2. Non configurare `authentication.runtimeProjection` per questo percorso: è la
proiezione delle configurazioni cookie local/OIDC, non l'identità Omics.
Nemmeno `THT_AUTH_RUNTIME_PROJECTION_ROOT` deve attivarla nel core.
3. Il descrittore e Compose base continuano a richiedere `authentication.configDirectory`
e `THT_AUTH_CONFIG_ROOT` coerenti. Per una nuova installazione upstream usare
una directory dedicata senza `auth.yaml`, non cancellare la configurazione di
un'installazione esistente. I cambi di modalità richiedono un piano separato.
4. Non esiste `tht auth configure --mode upstream`: il CLI configura gli utenti
locali o l'OIDC diretto. Conservare il percorso proxy già operativo per Omics.
5. `profile: server`, storage delle sessioni e `THOTH_PUBLIC_EXPOSURE` hanno propri
vincoli, che rimangono attivi. La shell embedded non li soddisfa automaticamente.
Il sorgente considera upstream un percorso di compatibilità con il proxy; è
quello usato dall'integrazione Omics corrente. `none` e `mock` sono per sviluppo/test,
non soluzioni a errori di configurazione in produzione.
## Intestazioni richieste all'ingresso del core
| Header | Regola ThothII | Valore Omics |
| --- | --- | --- |
| `X-Thoth-Principal-Issuer` | Stringa stabile, obbligatoria | `portal` |
| `X-Thoth-Principal-Subject` | ID stabile dell'utente, obbligatorio | `str(user.pk)` Django |
| `X-Thoth-Principal-Display-Name` | Facoltativo, se presente non vuoto | Nome completo o username |
| `X-Thoth-Is-Admin` | Obbligatorio: `0`, `1`, `false` o `true` | Risultato di `is_authentik_admin(user)` |
Le stringhe sono ripulite degli spazi esterni, devono avere al massimo 512
caratteri e non contenere caratteri di controllo. Header mancanti o invalidi
producono 401. `false`/`0` assegna il ruolo `user`; `true`/`1` assegna `user` e
`admin`. Il core espande i permessi dal proprio catalogo, non da un array inviato
dal browser. `/me` richiede `session.use`.
La coppia `(issuer, subject)` identifica il proprietario delle sessioni.
Non sostituire il subject con un nome visualizzato o un'email modificabile; non
cambiare issuer/subject di utenti esistenti per correggere un problema grafico.
Passare da identità `portal` a identità OIDC diretta non migra la proprietà dei dati.
## Omics: percorsi e componenti esatti
| Percorso | Destinazione e funzione |
| --- | --- |
| `/kokoro/datamart-builder/` | Pagina Django con `datamart_builder.access` |
| `/datamart-builder/config.js` | Config pubblico del frontend, senza cache |
| `/datamart-builder/assets/…` | Asset frontend risolti dal manifest Vite |
| `/datamart-builder/api/…` | Nginx con `auth_request`, poi core senza il prefisso |
| `/_thothii_auth` | Location Nginx interna, non un login pubblico |
| `/datamart-builder/api-auth` | Django verifica sessione Omics e capability |
Nel repository Omics:
- `kokoro/datamart_catalog_views.py`: `DatamartBuilderView` e
`datamart_builder_api_auth`; la verifica API risponde 200 o 403, anche 403
quando la sessione è assente/scaduta. Non trasforma l'API in una pagina di login.
- `nginx/nginx.conf`: API direttamente a `thothii-core:8787`, config e asset a
`thothii-frontend:8080`; verificare alias e reti Docker effettivi sul server.
- `templates/kokoro/datamart_builder.html`: mount, config e override del prefisso.
- `kokoro/templatetags/vite.py`: manifest da
`http://thothii-frontend:8080/.vite/manifest.json`, cache Django di 30 secondi.
Nginx usa il cookie Omics nella subrequest a Django. Sulle richieste al core
sovrascrive i quattro header con i risultati della verifica e rimuove
`Cookie`, `Authorization` e `X-Authenticated-User`. Nessuna password o token del
portale deve essere copiato nel config pubblico, nello snapshot adapter o in Web Storage.
`GET /datamart-builder/api/me` restituisce l'identità e i permessi; in upstream
`session` e `csrfToken` sono `null`: non viene creata una sessione-cookie ThothII.
## Non confondere i due percorsi proxy
L'esempio generico `deploy/nginx-authenticated-proxy.conf.example` usa **due hop**:
proxy host → frontend Nginx ThothII → core. Sul tratto privato verso il frontend
trasporta `X-Thoth-Trusted-Principal-*` e `X-Thoth-Trusted-Is-Admin`; il frontend
li converte nei quattro header del core e li elimina prima dell'inoltro.
Omics usa invece **Nginx Omics → core direttamente** per le API e invia gli header
normalizzati senza `Trusted`. Non incollare l'esempio a due hop in questa location:
la famiglia di header sbagliata produce 401. In entrambi i casi i valori devono
venire dalla verifica server, mai dagli header del client. Il tratto privato del
percorso generico deve essere inaccessibile ai client non fidati.
## Origine delle richieste e stream
Browser e API devono restare sullo stesso origin. Il frontend accetta `/api` o un
prefisso same-origin come `/datamart-builder/api`, non un URL `http://core:8787`.
In upstream le scritture con `Origin` sono confrontate con protocollo e Host
percepiti dal core; non usano il token CSRF della sessione ThothII local/OIDC.
Le richieste senza Origin hanno il trattamento non-browser: l'autenticazione del
proxy rimane indispensabile anche per esse.
Nel Nginx Omics esaminato il TLS termina a monte e una mappa **esatta** converte
`https://aritmolab.policlinicosandonato.it` in
`http://aritmolab.policlinicosandonato.it` per il confronto interno. Le altre origini
rimangono invariate e devono essere negate quando non coincidono. È una scelta
specifica della topologia corrente, non un modello da estendere con wildcard,
cancellazione di Origin o riscrittura incondizionata. Verificare Host/protocollo
al core e i dinieghi cross-origin nella topologia realmente rilasciata.
La location API disabilita buffering/cache per SSE e mantiene timeout lunghi.
`auth_request` verifica ogni nuova richiesta, ma non interrompe istantaneamente
uno stream già aperto quando il portale revoca l'utente. ThothII ricontrolla `/me`
al ritorno alla pagina e alla riconnessione degli eventi; non promettere revoca
istantanea fra tutte le schede.
## Logout, rientro e diagnosi
In embedded logout e successivo login sono di Omics. ThothII non chiama
`/auth/logout`, non cancella il cookie Django e non apre un suo login.
Il rifiuto 401/403 di `/me` rimuove lo stato protetto e richiede il rientro dal
portale. Un 403 su una singola operazione non equivale al logout dell'applicazione.
| Sintomo | Controllo mirato |
| --- | --- |
| Secondo header | Config servito: deve essere embedded, non full |
| Nessuna UI e errore preferenze | Selettore Omics `data-lang` e `html data-bs-theme` |
| `/me` 401 dal core | Header obbligatori, famiglia Trusted/normalizzata, percorso proxy |
| `/me` 403 dal proxy | Sessione Omics e capability `datamart_builder.access` |
| `/me` funziona ma POST 403 | Distinguere permesso operativo da mismatch Origin/Host/protocollo |
| 502 o asset assenti | Alias/rete Docker e manifest Vite; attesa cache manifest 30 s |
| Avvio core rifiutato | Coesistenza di `auth.yaml` o runtime projection con `AUTH_MODE` |
| Logout full seguito da rientro IdP immediato | Il logout ThothII non è logout globale OIDC |
Non raccogliere cookie, token, segreti o dump completi delle configurazioni nei
report. Registrare codici HTTP, nomi dei percorsi, revisioni e risultati dei test.
Consegna e rilascio: [procedura Omics](../operations/shell-and-localization.md#verifica-prima-del-deploy-server).
Collaudo obbligatorio: [matrice di accettazione](../testing/authentication-manual-acceptance.md).
+9 -2
View File
@@ -1,7 +1,14 @@
# Authentik provider configuration
ThothII uses generic OIDC in the browser. Authentik provides the identity provider and group
catalog without adding a proprietary login flow.
For **full with direct OIDC**, ThothII uses generic OIDC in the browser. Authentik
provides the identity provider and group catalog without adding a proprietary flow.
The provider/client/group setup below applies to that case only.
For **embedded in Omics**, retain Omics's existing Authentik authentication and
configure ThothII as upstream. Omics verifies `datamart_builder.access` and
administrator status and the proxy supplies the identity; no additional ThothII
OIDC client, login or local user is required for that path. Follow the
[portal integration guide](authentication-upstream.md).
```mermaid
sequenceDiagram
@@ -3,6 +3,9 @@
# Replace every absolute placeholder. Select exactly one Git transport override.
schemaVersion: 2
profile: local
shell:
mode: full
defaultLocale: en
projectDirectory: "/absolute/path/to/ThothII"
envFile: "/absolute/path/to/ThothII/deploy/env/local.env"
workspaceRepository:
@@ -3,6 +3,11 @@
# Replace every absolute placeholder. Select exactly one Git transport override.
schemaVersion: 2
profile: server
# Standalone server with protected direct OIDC auth, not the Omics upstream path.
# For Omics use authentication-upstream.md: embedded, no auth runtime projection.
shell:
mode: full
defaultLocale: en
projectDirectory: "/absolute/path/to/ThothII"
envFile: "/absolute/path/to/thothii-server-operator/server.env"
workspaceRepository:
+13 -1
View File
@@ -24,7 +24,7 @@ version control, a URL, or a command line.
From the repository root, start the interactive setup and select the local profile:
```sh
tht setup --profile local
tht setup --profile local --shell-mode full --shell-default-locale en
```
It writes the selected non-secret descriptor below `deploy/<installation-id>/`, the associated
@@ -32,6 +32,18 @@ operator env file, and can create protected secret templates. Keep the descripto
to commands as `--installation /absolute/path/thothii-installation.yaml` when more than one
installation can be discovered.
The explicit shell options are important: compatibility defaults without them
are embedded/en/omics-portal, which expects an Omics document. The Mac's standalone
installation must use full, with English as its initial locale. Existing browser
language preferences take precedence over that initial value. Full does not
configure authentication; setup separately bootstraps local login.
For a server portal, use the [embedded/upstream procedure](authentication-upstream.md)
instead of creating a second ThothII login. For a standalone server, use full
with [direct OIDC](authentication-oidc.md). Shell mode does not follow `profile`
automatically. See [configuration and regeneration](../operations/shell-and-localization.md)
before modifying an existing installation.
If the descriptor is prepared manually instead, begin with
[`thothii-installation.local.yaml`](examples/thothii-installation.local.yaml), set mode `0600` or
`0400`, and ensure `THT_INSTALLATION_CONFIG_SOURCE` in the selected env file points to that exact
+8
View File
@@ -1,5 +1,13 @@
# Docker installation in the current operating contexts
Rendering and authentication are independent of these Docker contexts. Explicitly
select full/en for the Mac, full/OIDC for an autonomous server, or
embedded/upstream for Omics. The same frontend image supports both renderings;
generated `config.js` and the host page decide the container, while the backend
and trusted proxy decide identity. See [shell configuration and deploy](operations/shell-and-localization.md)
and [server portal authentication](install/authentication-upstream.md). Do not
apply the standalone server authentication projection to the Omics upstream path.
ThothII uses one Compose topology:
- `frontend`
+378
View File
@@ -0,0 +1,378 @@
# Consegna a Codex sul server: ThothII e Omics Portal
Revisione: **14 settembre 2026**. Destinazione: Datamart Builder nel portale
Omics esistente, non un nuovo sito standalone. Questo documento è la procedura
di riferimento per questa consegna e sostituisce le precedenti istruzioni di
trasporto/pubblicazione del codice Omics. La distribuzione parte dai sorgenti
ThothII aggiornati su `main` e dal branch Omics disponibile su GitHub; nessuna
replica del repository Omics ad altri servizi fa parte dell'intervento.
## Risultato da ottenere e limiti
- L'utente entra in Omics come oggi, sceglie **Datamart Builder** e trova ThothII
già autenticato, senza un secondo login.
- Omics mantiene header, navigazione sinistra, lingua, tema, fullscreen, nome
utente e logout. ThothII occupa soltanto la zona centrale: **embedded/upstream**.
- I dati e le identità esistenti, i workspace, i modelli approvati e le credenziali
del server restano quelli del server. Il Mac rimane **full/local**, default EN.
- L'intervento comprende il codice e la configurazione di entrambi gli
applicativi, la rigenerazione delle proiezioni, le immagini e il collaudo.
Un pull da solo non conclude l'installazione.
Codex può fare l'inventario, preparare modifiche e test isolati. Prima del fermo,
delle migrazioni, della modifica del proxy o della ricreazione di servizi
operativi, presenta i comandi risolti, backup e rollback e ottieni conferma della
finestra di rilascio. Ferma il passaggio interessato se manca una credenziale,
una decisione sulla migrazione o un prerequisito; non aggirare i controlli.
Non modificare Authentik o il DWH per correggere la UI. Il DWH resta read-only.
Non cancellare volumi, dati o modifiche locali e non stampare segreti nei report.
## 1. Identificare l'installazione realmente in uso
Leggi `AGENTS.md` e `PROJECT_STATE.md` nel checkout ThothII aggiornato. Individua
il checkout operativo Omics, normalmente `/home/chirone/omics_portal`; conferma
il percorso prima di usarlo. Registra per **entrambi** i progetti:
1. Percorso, branch, SHA, stato della working tree e revisioni delle immagini
effettivamente in esecuzione. Il checkout appena aggiornato può non coincidere
con quello da cui sono stati creati i container.
2. Nomi progetto Compose, file Compose/override ordinati, env file, servizi,
mount, porte e reti. Leggi le label Compose dei container per ricostruire
l'avvio; filtra gli inspect, evitando dump di variabili segrete.
3. Percorso assoluto del `thothii-installation.yaml`, suo schema/profile,
`projectDirectory`, `envFile`, `overrides`, `authentication`, `shell` e modello
dei dati persistenti. Usa solo percorsi Linux reali e file che esistono.
4. Configurazione effettiva del core: modalità auth, `THOTH_PUBLIC_EXPOSURE`,
`THT_SESSION_STORAGE`, binding workspace/database, percorsi degli archivi
Evidence e Memory e delle credenziali Pi/provider.
5. Origine HTTPS pubblica del portale, punto di terminazione TLS, percorso
autenticato delle API, alias di rete e possibilità di accesso diretto al core.
**Completato quando:** esiste un inventario senza segreti e ogni comando di
avvio è ricostruibile con percorsi/progetti effettivi. Non usare gli script
temporanei `/private/tmp/…` o i percorsi `/Users/mp/…` del Mac. Gli override vanno
conservati in un percorso operativo stabile sul server.
## 2. Verificare le revisioni dei due applicativi
### ThothII
Il checkout aggiornato deve essere su `main` e includere almeno
`bdcd8fcd28f3011471d77224db9c3f5baf227995` e questo documento. Registra anche lo
SHA effettivo di `main`, che include il commit di consegna e il merge successivi:
```bash
git status --short --branch
git rev-parse HEAD
git merge-base --is-ancestor bdcd8fcd28f3011471d77224db9c3f5baf227995 HEAD
```
Se il controllo fallisce, completa l'acquisizione della revisione approvata
prima di toccare l'installazione. Non ricostruire a mano le singole modifiche UI:
questa revisione contiene shell, autenticazione, i18n, workflow bilingue,
amministrazione, typography e navigazione aggiornate.
### Omics Portal
Il pull di ThothII **non aggiorna Omics**. Consegna Omics verificata su GitHub:
- Repository: `https://github.com/Dallavilla-Tiziano/omics_portal.git`.
- Branch: `codex/thothii-embedded-shell`.
- SHA della consegna: `fca10901a73666ca257d8f4cc4b77066295c400a`.
- Commit funzionale della shell: `95154e179144e2453b37ef2a63a65d6f377e4cf8`.
Nel checkout Omics confermato, acquisisci senza fare un pull/merge implicito:
```bash
cd /home/chirone/omics_portal
git status --short --branch
git rev-parse HEAD
git fetch --no-tags https://github.com/Dallavilla-Tiziano/omics_portal.git \
refs/heads/codex/thothii-embedded-shell:refs/remotes/thothii-delivery/omics-shell
git rev-parse refs/remotes/thothii-delivery/omics-shell
git merge-base --is-ancestor 95154e179144e2453b37ef2a63a65d6f377e4cf8 \
refs/remotes/thothii-delivery/omics-shell
git diff --stat HEAD...refs/remotes/thothii-delivery/omics-shell
```
Confronta lo SHA acquisito con quello sopra. In caso di consegna diversa chiedi
quale revisione usare. Confronta inoltre le modifiche con i progressi del server:
non sostituire l'intero portale con un checkout più vecchio. Se la consegna è già
integrata verifica i file, senza ripetere il merge; altrimenti prepara la sua
integrazione in un branch/worktree di revisione dal codice operativo. Risolvi
eventuali conflitti preservando i cambiamenti del server, testa, quindi applica
la revisione concordata nel rilascio. Non fare reset o force push.
I dettagli tecnici locali in `docs/thothii-integration.md` di Omics sono utili,
ma il percorso operativo di questa consegna è quello di **questo documento**.
La pubblicazione del codice Omics su altri remote non è un prerequisito.
**Completato quando:** una revisione integrata Omics conserva le funzionalità del
server e soddisfa tutti i controlli dei file nella sezione 4.
## 3. Adeguare ThothII senza importare la configurazione del Mac
### CLI, descrittore e proiezioni
Aggiorna il **CLI nativo host** dal checkout ThothII approvato, conservando il
vecchio binario per rollback. Non confonderlo con il CLI Python interno al core:
```bash
./scripts/install-tht.sh
command -v tht
tht --help
```
Il comando installa normalmente in `/usr/local/bin` e verifica la risoluzione
su PATH. Se il server usa un'altra directory, mantieni quella usando
`THT_INSTALL_DIRECTORY` con un percorso assoluto. Conserva proprietario e
permessi protetti del descrittore (`0600` o `0400`). Nel descrittore esistente
schema v2 modifica la sezione seguente, preservando gli altri valori:
```yaml
shell:
mode: embedded
defaultLocale: en
adapter: omics-portal
```
`en` è il fallback UI, non forza l'inglese sul portale: in embedded prevale la
lingua renderizzata da Django. Mantieni il `profile` server approvato e i percorsi,
la project/installation identity, lo storage e gli override del server. Se il
descrittore manca o è legacy, prepara una migrazione separata dopo l'inventario;
non rilanciare il setup locale e non copiare il descrittore Mac.
Usa una variabile di lavoro dedicata, valorizzata con il percorso **confermato**:
```bash
THTII_INSTALLATION=/percorso/reale/thothii-installation.yaml
tht --installation "$THTII_INSTALLATION" installation generate
```
La generazione non avvia i servizi. Controlla `generated/frontend/config.js` e
il suo mount read-only nel Compose generato; prima dell'override Omics deve
contenere `backendBaseUrl: "/api"` e la shell embedded completa. Conserva anche
le proiezioni generate di modelli/settings/Pi. Non mantenere copie manuali dei
file generati: `thothii-installation.yaml` resta la sorgente authored.
### Autenticazione già fornita dal portale
Nell'override Compose persistente dell'installazione deve esserci:
```yaml
services:
core:
environment:
AUTH_MODE: upstream
```
Aggiungi il percorso dell'override all'elenco `overrides` del descrittore se non
è già caricato. Controlla il Compose risolto: scrivere `AUTH_MODE` nel solo file
env non garantisce che la variabile arrivi al container.
- `authentication.configDirectory` e `THT_AUTH_CONFIG_ROOT` devono indicare la
directory protetta prevista, **senza un `auth.yaml` local/OIDC letto dal core**.
Non cancellare un file esistente: se trovato, fermati e prepara il cambio auth
con backup e una directory dedicata. `AUTH_MODE` insieme al file è rifiutato.
- Per Omics non usare `authentication.runtimeProjection` né
`THT_AUTH_RUNTIME_PROJECTION_ROOT`: appartengono all'accesso local/OIDC diretto.
- Non creare utenti/password ThothII, nuovi client OIDC o callback per questo
embedding. Non esiste `tht auth configure --mode upstream`.
- Preserva la coppia stabile `(issuer, subject)` degli utenti (`portal`, ID
Django); cambiarla può rendere invisibili le sessioni dei proprietari esistenti.
### Dati, storage e prerequisiti non grafici
La release corrente usa catalogo metadati interno PostgreSQL, workspace schema
v4, Installation Model Catalog v2, Qdrant e Ollama per embedding; Pi gira nel
core. Mantieni i provider e i binding reali del server, incluse credenziali e CA.
I default del Mac non sono una richiesta di cambiare modello o database.
Il catalogo metadati e l'eventuale database delle sessioni sono **due funzioni
distinte**. Con `THOTH_PUBLIC_EXPOSURE=true`, il core rifiuta
`THT_SESSION_STORAGE=local`: verifica che il percorso PostgreSQL delle sessioni
sia già configurato e validato. Se manca, presenta il piano di provisioning e
migrazione; non disabilitare il controllo public-exposure per ottenere l'avvio.
L'overlay session-server è opt-in e non migra automaticamente i vecchi archivi.
Se la versione operativa precede questi contratti, risolvi prima la migrazione
dei dati con backup verificati. Le migrazioni del catalogo si eseguono tramite
il job esplicito `catalog-migrate`; quelle delle sessioni, quando necessarie e
approvate, tramite `session-migrate`. Nessuna riguarda il DWH o è sostituita da
una sincronizzazione di schema dall'interfaccia.
**Completato quando:** il descrittore genera correttamente; la configurazione
risolta contiene shell embedded, upstream senza doppia auth, storage compatibile,
mount e reti corretti; ogni differenza infrastrutturale ha un piano approvato.
## 4. Verificare e integrare i file Omics
| File nel repository Omics | Risultato obbligatorio |
| --- | --- |
| `templates/kokoro/datamart_builder.html` | Mount `#root` nello stesso documento Django, senza iframe; altezza contenuta sotto la topbar e layout centrale responsive. Carica config, override limitato e asset in quest'ordine. |
| `templates/base.html` | `{% get_current_language as CURRENT_LANGUAGE %}` e `<html lang="{{ CURRENT_LANGUAGE }}">`, preservando i block del template. |
| `templates/partials/topbar.html` | `select.omics-language-select[data-lang]` con lingua Django, form `set_language` POST/CSRF/next; pulsante fullscreen con label ingresso/uscita e stato accessibile. Mantieni nome/logout Omics. |
| `static/js/app.js` | Fullscreen reale del documento con `requestFullscreen`/`exitFullscreen`; ascolta gli eventi del browser, inclusa uscita con Esc, aggiorna icona/stato/label e gestisce rifiuti senza simulare successo. |
| `locale/it/LC_MESSAGES/django.po` | Traduzioni dei nuovi controlli fullscreen; compilazione del catalogo distribuito. |
| `kokoro/datamart_catalog_views.py` e routing | La pagina richiede `datamart_builder.access`; l'endpoint `/datamart-builder/api-auth` verifica la sessione Django e restituisce identità verificata o 403. Conserva questa parte già esistente. |
| `nginx/nginx.conf` | API protette verso il core, asset/config verso il frontend, origine e SSE coerenti. Conserva anche le altre route del portale. |
| `kokoro/templatetags/vite.py` | Manifest da `http://thothii-frontend:8080/.vite/manifest.json`, asset dal manifest, cache di 30 secondi. |
| `kokoro/test_thothii_shell.py`, `test_support/thothii/` | Test isolati della pagina e dei controlli, da eseguire prima del rilascio. |
Il template deve fare questo **prima** di `{% vite_assets %}`:
```html
<script src="/datamart-builder/config.js"></script>
<script>
window.__THOTHII_CONFIG__ = Object.assign({}, window.__THOTHII_CONFIG__ || {}, {
backendBaseUrl: '/datamart-builder/api'
});
</script>
```
L'override non deve sostituire l'intero oggetto perdendo `shell`. Nel browser il
risultato deve avere `/datamart-builder/api` e `shell.mode === "embedded"`.
`config.js` deve avere `Cache-Control: no-store`; gli asset con hash possono
avere cache lunga. Non codificare a mano i nomi dei bundle Vite.
Il tema deve essere espresso come `data-bs-theme="light"` o `"dark"` su `html`.
`OmicsPortalAdapter` in ThothII osserva quel dato, legge il `data-lang` renderizzato
dal selettore e lo stato fullscreen. Non richiede nuovi eventi, handshake o
token JavaScript. Se il contesto manca va corretto il template, non aggirato
l'errore con un header full. I dettagli del portale restano nel solo adapter.
### Proxy e identità: controllo obbligatorio
Il flusso è browser → Nginx Omics → controllo Django → core ThothII:
1. `/datamart-builder/api/…` usa `auth_request /_thothii_auth`.
2. La location interna interroga `/datamart-builder/api-auth` usando il cookie
Omics. Django risponde 200 se autorizzato, 403 senza sessione/capability.
3. Nginx usa solo gli header **della risposta Django** e sovrascrive gli eventuali
valori client: `X-Thoth-Principal-Issuer: portal`,
`X-Thoth-Principal-Subject: <user.pk>`, `X-Thoth-Principal-Display-Name` e
`X-Thoth-Is-Admin: true|false` secondo `is_authentik_admin(user)`.
4. Il proxy rimuove `Cookie`, `Authorization` e `X-Authenticated-User` prima del
core, toglie il prefisso API e inoltra a `thothii-core:8787`. Non usare qui
gli header `X-Thoth-Trusted-*` dell'esempio generico a due hop.
5. Config/asset e manifest arrivano da `thothii-frontend:8080`. Omics web deve
raggiungere il manifest; Nginx deve raggiungere entrambi gli alias privati.
Integra i servizi nella rete effettiva del portale, con alias non ambigui;
non collegare due core candidati con lo stesso alias. Il core upstream deve
essere irraggiungibile direttamente da browser/client non fidati, inclusi
percorsi alternativi attraverso un frontend o proxy non protetto.
Conserva buffering/cache disattivati e timeout lunghi per SSE anche nel proxy
a monte. Verifica l'Origin delle scritture: il codice Omics contiene la mappa
esatta da `https://aritmolab.policlinicosandonato.it` a
`http://aritmolab.policlinicosandonato.it` per la terminazione TLS esterna.
Conferma che la topologia sia ancora quella. Se differisce, correggi Host,
protocollo e mappa esatta con un test di rifiuto cross-origin; non cancellare
Origin, non usare wildcard né rendere fidati gli header forniti dal browser.
Riferimento per errori 401/403 e contratto completo:
[autenticazione upstream](../install/authentication-upstream.md).
## 5. Test, backup e rilascio coordinato
Dal checkout Omics integrato, senza database operativo o volumi collegati:
```bash
docker build -f test_support/thothii/Dockerfile -t omics-portal:thothii-shell-tests .
docker run --rm --network none omics-portal:thothii-shell-tests
```
In ThothII verifica la build di frontend/core, i test auth e shell e la
generazione del descrittore con il CLI aggiornato. I test locali alla consegna
includono 768 test frontend, 20 scenari browser e build documentale strict;
non certificano il portale/IdP né i dati del server.
Prima del rilascio prepara un piano con i **comandi esatti risolti**. Per
installazioni già governate dal CLI usa `tht --installation …`; per launcher
server personalizzati conserva progetto, ordine di tutti gli override e bind.
Non alternare i due lifecycle se cambiano la project identity o i volumi.
Ordine da applicare nella finestra confermata:
1. Metti al sicuro revisioni, binario host, descrittore/env/override, immagini e
backup consistenti di catalogo, sessioni, registry/Evidence/Memory, settings,
Pi e indici. Proteggi i backup che contengono segreti. Verifica il ripristino
prima di una migrazione non reversibile e gestisci le sessioni in corso.
2. Genera le proiezioni dell'installazione; costruisci **core e frontend** dai
sorgenti approvati. Avvia i servizi di supporto necessari e, se richiesto dal
salto di versione, esegui le migrazioni esplicite con exit 0 prima del core.
Il normale `tht start --build` coordina il lifecycle, non è frontend-only.
3. Ricrea i servizi applicativi ThothII con configurazione embedded/upstream e
conserva il progetto/dati approvati. Controlla health e diagnostica:
```bash
tht --installation "$THTII_INSTALLATION" status
tht --installation "$THTII_INSTALLATION" doctor --json
```
4. Distribuisci la revisione Omics integrata tramite il suo normale rilascio,
includendo `web`, statici/cataloghi e configurazione `nginx`. Il suo entrypoint
esegue `migrate`, `compilemessages` e `collectstatic`: le modifiche della shell
non aggiungono migrazioni Django, ma controlla quelle pendenti del server prima
del riavvio. Verifica anche il catalogo Superset richiesto dalla build Omics.
5. Esegui `nginx -t` nel servizio candidato e applica il reload/riavvio secondo
la topologia registrata. Dopo la ricreazione dei container verifica che il
proxy risolva gli alias ai nuovi indirizzi, non a IP Docker precedenti.
6. Attendi almeno 30 secondi per la cache manifest Django o invalidala con il
meccanismo del portale. Verifica asset/config e svolgi il collaudo seguente.
Se uno step fallisce non marcare l'installazione conclusa. Un core healthy non
prova che auth, UI embedded o scritture attraverso il proxy funzionino.
## 6. Accettazione prima di dichiarare completato
Usa account di prova autorizzati e dati non operativi per i test che scrivono.
Le verifiche che richiedono login interattivo possono essere svolte dall'operatore:
riporta esplicitamente quelle ancora da fare, senza spuntarle per deduzione.
- **Accesso:** login Omics, apertura da menu, nessun login/header ThothII. `/me`
su `/datamart-builder/api/me` restituisce identità e permessi corretti;
`session`/`csrfToken` sono null in upstream.
- **Dinieghi:** senza sessione o capability il proxy nega; header principal
falsificati non danno accesso. Utente normale senza controlli admin; admin
autorizzato con controlli coerenti. Nessuna route diretta aggira il proxy.
- **Lingua e continuità:** IT/EN prima e dopo l'apertura, cambio attraverso Omics,
ripristino della selezione dopo reload senza generazione automatica. Nuove
sessioni ricevono la lingua UI; sessioni riprese mantengono
`interaction_language`. Per quelle legacy la prima ripresa fissa la lingua
del workspace in modo idempotente. SQL e contenuti authored non sono tradotti.
- **Tema/fullscreen:** light/dark cambia anche ThothII, incluse finestre e menu;
fullscreen nasconde il bordo browser, sostituisce l'icona e torna normale con
Esc. Header/sidebar Omics mantengono il proprio aspetto.
- **Logout:** il logout è soltanto quello Omics. Ritorno alla pagina e
riconnessione ricontrollano l'accesso; non promettere revoca istantanea di uno
stream già aperto in un'altra scheda.
- **Workflow:** una sessione di prova autorizzata può essere creata, ricevere
eventi SSE e domande/scelte nella lingua corretta, salvare e riprendere senza
perdere proprietà. Le scritture same-origin funzionano, quelle cross-origin
non autorizzate vengono negate.
- **Amministrazione/UI:** Database, Memory ed Evidence leggibili, font/layout
aggiornati e nessuna propagazione del reset CSS alla topbar Omics. Le memory
FAKE sono solo esempi UI isolati, non da importare nel catalogo o nel recall.
Puntino readiness Workspace, unico bottone Sessione, tab con bordi uniformi;
accordion inizialmente chiuso, un solo pannello aperto, selezione per lista,
scroll interno e nessuna frase “Inizia con Sessione”.
- **Operatività:** nessun errore di config/auth nei log, mount e permessi corretti,
indici/cataloghi e dati precedenti disponibili, nessuna modifica al DWH/IdP.
Compila il report con SHA ThothII/Omics, immagini, percorsi configurazione,
comandi eseguiti, risultati e prove manuali pendenti, senza cookie o token.
La [matrice auth completa](../testing/authentication-manual-acceptance.md)
approfondisce i casi di sicurezza.
## 7. Rollback
Ripristina la coppia compatibile di codice/immagini **Omics e ThothII**, il CLI,
descrittore e proiezioni registrati, seguendo il lifecycle approvato. Riavvia il
proxy se necessario per DNS/config e ricontrolla manifest, accesso e SSE.
I dati restano preservati: nessun `down --volumes`, cancellazione di archivi o
reset distruttivo. Se il rilascio ha migrato uno schema o scritto dati non
compatibili con la versione precedente, usa il piano di ripristino dati approvato,
non un semplice downgrade d'immagine. Il rollback termina solo dopo il collaudo
della versione ripristinata.
@@ -4,6 +4,22 @@ Questo runbook è il passaggio di consegne per il Codex che opererà sul server
installazione precedente alla configurazione corrente di ThothII senza modificare Authentik o il
DWH esterno e senza cancellare lo stack precedente durante il primo cutover.
## Scelta preliminare: server autonomo oppure Omics
I passaggi di questo runbook che configurano OIDC diretto, gruppi e
`authentication.runtimeProjection` riguardano **ThothII autonomo**, da rendere
con `shell.mode: full`. Non applicarli all'integrazione Datamart Builder: Omics
usa **embedded/upstream** e mantiene il proprio accesso Authentik. Il core riceve
l'identità verificata dal proxy senza un secondo login né un secondo auth.yaml.
Prima dell'inventario identificare quale percorso è approvato. Per Omics seguire
[autenticazione upstream](../install/authentication-upstream.md) e
[rilascio coordinato dei due repository](shell-and-localization.md#preparare-il-rilascio-coordinato);
i gate di backup, isolamento, catalogo, storage e rollback di questo runbook
rimangono validi, ma non copiare i passi auth del percorso autonomo. Il passaggio
da issuer `portal` a un issuer OIDC differente non trasferisce automaticamente
la proprietà delle sessioni.
La procedura si applica a `main` quando contiene almeno il commit
`eba6148511675fc6a187aabb69a975adc5e3c542`. Deve essere presente anche questo file. Il commit
minimo è un controllo di sicurezza, non un invito a fermarsi a quella revisione: installare sempre
@@ -11,6 +27,9 @@ la `origin/main` approvata dall'operatore.
## Regole non negoziabili
- Per il rilascio Omics seguire la [consegna corrente a Codex sul server](server-codex-handoff.md).
Acquisire il branch dedicato da GitHub, verificare SHA e integrarlo con i
progressi del server; nessuna replica del repository è richiesta.
- Eseguire prima l'intero inventario in sola lettura e consegnarlo all'operatore.
- Non stampare mai password, token, chiavi private, cookie, file `.env` o contenuti dei Docker
secret. Nei report sono ammessi solo percorsi, nomi delle variabili e valori non segreti.
@@ -51,7 +70,9 @@ La topologia base attesa è:
| `embedding-model-init` | scarica/verifica il modello | job one-shot, deve terminare con exit 0 |
`catalog-db` non è il DWH. Non pubblica porte sull'host e usa credenziali runtime e migrator
separate. Ollama non controlla le password utente: l'autenticazione resta OIDC tramite Authentik.
separate. Ollama non controlla le password utente: nel percorso autonomo vale
OIDC tramite Authentik; nel percorso embedded Omics verifica l'accesso e il core
usa upstream.
Il PostgreSQL per le **sessioni** è un'altra funzione ancora. L'overlay
`deploy/compose.session-server.yaml.example` è esplicitamente opt-in: non abilitarlo durante questo
+319
View File
@@ -0,0 +1,319 @@
# Shell, autenticazione e lingue
Questa è la procedura operativa del rendering corrente. Leggerla insieme a
[architettura full/embedded](../architecture/application-shell.md),
[autenticazione upstream](../install/authentication-upstream.md) e
[contratto PortalAdapter](../contracts/portal-shell-adapter-v1.md).
La [specifica approvata](../plans/2026-09-13-full-shell-spec.md) documenta la
progettazione, non sostituisce i vincoli verificati nel codice e riportati qui.
## 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.
| Destinazione | Shell | Autorità di accesso | Login/logout visibile |
| --- | --- | --- | --- |
| Mac attuale | full, default en | `auth.yaml` local | ThothII |
| Server autonomo | full | `auth.yaml` OIDC | ThothII, con redirect al provider |
| Datamart Builder in Omics | embedded, adapter Omics | core `AUTH_MODE=upstream`, sessione Omics al proxy | Solo Omics |
Non confondere la lingua inglese iniziale del Mac con quella del workspace o
delle sessioni già create. Non copiare sul server l'intero descrittore del Mac:
contiene percorsi e scelte locali, oltre a full.
Per questo Mac, nel descrittore installato:
```yaml
shell:
mode: full
defaultLocale: en
```
Per il server Omics:
```yaml
shell:
mode: embedded
defaultLocale: en
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
Per una nuova installazione autonoma, selezionare esplicitamente full:
```bash
tht setup --profile local --shell-mode full --shell-default-locale en
```
Il setup senza opzioni shell conserva per compatibilità il default embedded.
Per un'installazione esistente non rilanciare setup per sovrascrivere il
descrittore: registrare la configurazione attuale, modificarne la sezione shell
e usare la generazione seguente. Le credenziali rimangono nei file protetti.
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à.
Nel percorso standard del CLI, dopo avere approvato l'aggiornamento:
```bash
tht --installation /percorso/assoluto/thothii-installation.yaml start
tht --installation /percorso/assoluto/thothii-installation.yaml status
tht --installation /percorso/assoluto/thothii-installation.yaml doctor --json
```
Usare `start --build` per una revisione di codice che richiede nuove immagini,
non per la sola modifica della shell. Questo è un lifecycle dell'installazione,
non un comando garantito frontend-only. Un launcher personalizzato deve conservare
tutti gli override di rete, autenticazione, workspace e modelli già approvati.
Non usare `down --volumes`. Registrare gli identificatori delle immagini prima
dell'aggiornamento e conservare il descrittore precedente per il rollback.
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.
Il file pubblico standalone deve essere equivalente a:
```javascript
window.__THOTHII_CONFIG__ = {
backendBaseUrl: "/api",
shell: { mode: "full", defaultLocale: "en" }
};
```
È un risultato da controllare, non un file da mantenere a mano. In Omics il
template carica `/datamart-builder/config.js`, conserva l'oggetto con
`Object.assign` cambiando solo `backendBaseUrl` in `/datamart-builder/api`, poi
carica gli asset dal manifest. Il config senza cache deve precedere ogni modulo
React; verificare nella rete del browser l'URL finale `/datamart-builder/api/me`.
## 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.
Full/upstream non può terminare una sessione posseduta dal proxy e non mostra
quel comando logout. Embedded non presenta mai il login ThothII, neppure per
recuperare un errore di configurazione. Per un server autonomo con login/logout
ThothII usare full/OIDC, non full/upstream.
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.
La [guida upstream](../install/authentication-upstream.md) riporta i vincoli
esatti: `AUTH_MODE=upstream` nel core, nessun `auth.yaml` o runtime projection
contemporaneo, capability Django, quattro header obbligatori/facoltativi, percorso
diretto Omics distinto dal proxy generico a due hop, origine e SSE. Non usare
`tht auth configure --mode oidc` per «completare» l'accesso Omics già funzionante.
## 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
### Acquisire prima le modifiche al repository Omics
Procedura aggiornata il 14 settembre 2026: acquisire il codice Omics da GitHub
e integrarlo con il codice operativo del server. Non è richiesta alcuna replica
del repository su altri servizi; l'eventuale copia è un'attività distinta del
proprietario. Questa indicazione sostituisce le precedenti note di trasporto,
anche se ancora presenti nei documenti storici del branch Omics.
La [consegna corrente a Codex sul server](server-codex-handoff.md) contiene i
comandi esatti di acquisizione, gli SHA, i file da adeguare, la configurazione
embedded/upstream, i test, i gate di rilascio e il rollback. Usarla come procedura
ordinata per l'aggiornamento; le sezioni qui sotto restano il riepilogo tecnico.
Consegna GitHub riverificata: branch `codex/thothii-embedded-shell` di
`https://github.com/Dallavilla-Tiziano/omics_portal.git`, SHA
`fca10901a73666ca257d8f4cc4b77066295c400a`, incluso il commit funzionale
`95154e179144e2453b37ef2a63a65d6f377e4cf8`. Il pull di ThothII non aggiorna
Omics: il checkout del portale, normalmente `/home/chirone/omics_portal`, va
verificato e integrato separatamente preservando le modifiche successive del server.
### Preparare il rilascio coordinato
1. Registrare SHA approvati di **entrambi** i repository, immagini precedenti,
descriptor ThothII, file Compose/override e progetto realmente in uso. La testa
del branch di lavoro non è automaticamente una revisione approvata di produzione.
2. In un checkout di revisione separato, integrare Omics con il branch di rilascio
concordato. Non fare merge nel checkout operativo con modifiche altrui.
I file Omics da includere sono template Datamart Builder/topbar/base, asset
fullscreen e cataloghi Django del branch; conservare la verifica server
esistente in `kokoro/datamart_catalog_views.py` e le location Nginx protette.
3. Eseguire dal checkout Omics i test isolati, non i test contro il database operativo:
```bash
docker build -f test_support/thothii/Dockerfile -t omics-portal:thothii-shell-tests .
docker run --rm --network none omics-portal:thothii-shell-tests
```
4. Predisporre il descrittore ThothII embedded e il core upstream secondo la guida.
Verificare che Nginx Omics possa raggiungere gli alias privati `thothii-core:8787`
e `thothii-frontend:8080` e che non esista un ingresso non protetto al core.
Non sovrascrivere rete, mount o autenticazione usando il Compose locale del Mac.
5. Solo dopo il gate operatore, applicare le revisioni approvate seguendo il
lifecycle dei due progetti. In Omics i servizi sono `web` e `nginx`: includere
nel rebuild template, statici e cataloghi, mantenendo tutti gli override del
server. Verificare la configurazione Nginx con `nginx -t` nel servizio e lo
stato di entrambi. Non inventare opzioni Compose/progetto: usare quelle
registrate al punto 1. Gli entrypoint del portale possono avere altri effetti
operativi: questa modifica non richiede nuove migrazioni DB, ma non autorizza
a bypassare i controlli del suo rilascio.
6. Dopo l'aggiornamento del frontend, attendere la cache manifest Django (30 s)
oppure usare l'invalidazione prevista dal portale; ricaricare e controllare
config/asset/prefisso API prima di giudicare il risultato.
7. Compilare la matrice seguente. In caso di errore ripristinare revisioni,
immagini e configurazioni registrate, senza cancellare volumi. Il rollback
deve conservare una coppia compatibile di template Omics e frontend ThothII.
La consegna GitHub è verificata; il deploy della revisione integrata
Omics rimane da confermare dall'operatore. I test locali non attestano lo stato
attuale del server remoto.
### Accettazione dell'integrazione
Usare la [matrice completa full/embedded e autenticazione](../testing/authentication-manual-acceptance.md),
registrando per ogni prova revisione, ambiente e risultato. Non spuntare i casi
IdP/Omics reali soltanto perché passano i test con risposte simulate.
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à.
@@ -2,6 +2,12 @@
Stato: **pubblicata per revisione visiva, non integrata in main**.
Aggiornamento 2026-09-13: i sette commit di questa revisione sono ora integrati con
full/embedded e i18n nel ramo `codex/prototype-administration-pages`, su richiesta
del proprietario. Per lo stato corrente e il rollback usare il
[rapporto di integrazione](2026-09-13-visual-shell-integration.md).
Il seguito conserva la cronologia della consegna isolata originale.
## Dove provarla
- Docker locale: <http://127.0.0.1:8080/>.
@@ -0,0 +1,134 @@
# 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).
Correzione della base grafica: la prima build descritta qui non includeva i sette
commit del ramo `codex/ui-visual-review` già installati sul Mac. Il
[rapporto di integrazione](2026-09-13-visual-shell-integration.md) documenta il
recupero di font, allineamenti e layout, mantenendo le funzionalità di questa consegna.
## 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,105 @@
# Ritocchi header e layout
Data: 13 settembre 2026. Ambito: frontend ThothII e installazione locale del Mac.
Nessuna modifica al repository Omics o deploy sul server PSD.
## Modifiche
- Header full `#CB333B`, valore di `--gsd-red-primary` in Omics
`static/css/gsd-theme.css`. Marchio completo e controlli chiari in entrambi i
temi; opzioni lingua, hover, focus ed errori rimangono leggibili. Gli altri
marchi conservano il suffisso rosso. Embedded non aggiunge alcun header.
- Catalog, Operations e Metadata updated separati di 16px dal divisore superiore.
- Due selettori e Done allineati in un'unica riga desktop; controlli alti 32px.
Il contenitore stretto impila i controlli. Retry, blocchi operativi e messaggi
di errore rimangono disponibili; le scelte mantengono lo stesso comportamento.
- Bordo inferiore rimosso dai tab My sessions e All sessions; stato selezionato,
focus e navigazione da tastiera conservati.
Impeccable, registro product, ha guidato il mantenimento della gerarchia esistente
e dell'adattamento agli schermi stretti, senza introdurre nuovi componenti.
## Verifiche
Typecheck superato; 71 test nelle suite AppShell.host, AppShell.administration e
AppShell.session-mgmt; 11 scenari Playwright della revisione visuale superati.
Le nuove asserzioni controllano colore header light/dark, colore del suffisso,
spazio degli stati, posizione di Done, assenza del bordo e overflow. Verificati
gli screenshot desktop/mobile e scuro; le transizioni sono completate prima della
cattura. Fixture sintetiche, nessun modello o dato reale modificato dai test.
Build Docker frontend superata, inclusi test del CSS compilato. Rimane l'avviso
preesistente Vite sui bundle maggiori di 500 kB. Il CSS servito dal Mac include
il rosso Omics, le azioni inline e la rimozione del bordo; configurazione full/en
confermata. Container frontend `8a1608ad7fc6`, immagine `a2f489ebe9de`, healthy.
Core `3c3c0739b9af`, catalogo `89755e439dc7`, Qdrant `52ebd5c47955` ed embedding
`7f690e534eda` sono invariati e healthy.
## Aggiornamento e rollback
È stato ricostruito e ricreato soltanto il frontend attraverso il launcher
`/private/tmp/thothii-memory-preview.sh`, con `up -d --no-deps --no-build --wait frontend`.
L'immagine precedente è conservata come
`thothii-frontend:before-header-layout-20260913`. Per ripristinarla sul Mac:
```bash
docker image tag thothii-frontend:before-header-layout-20260913 thothii-frontend:local
bash /private/tmp/thothii-memory-preview.sh up -d --no-deps --no-build --wait frontend
```
Per tornare alla revisione nuova, ricostruire il frontend dal branch aggiornato
e ripetere il comando di avvio. Non toccare volumi, Core o configurazione Omics.
## Follow-up: login e focus del selettore lingua
Rimossi dalla form login il soprattitolo «Accesso a ThothII», il relativo lucchetto
decorativo e la spiegazione «Usa l’account della tua installazione per continuare.».
Il titolo «Accedi a ThothII» rimane. La modifica vale in inglese e italiano.
I select nativi possono mantenere `:focus-visible` anche dopo un clic. Il selettore
lingua distingue ora il focus da puntatore: niente outline esterno dopo il clic;
il normale bordo del controllo rimane. Il ritorno con Tab e l'interazione da
tastiera conservano l'indicatore visibile. Escape chiude il menu senza cambiare
la modalità di focus. Nessuna sfocatura forzata o modifica all'header Omics.
Verificati 36 test LoginPage/AuthGate/AppShell.host e 14 scenari browser, inclusi
login EN/IT e clic, Escape, Tab/Shift+Tab in entrambi i temi. Screenshot controllati;
nessuna autenticazione reale o sessione utente è stata modificata dalle fixture.
Typecheck e build Docker superati. Impeccable ha guidato la rimozione dei testi
ridondanti mantenendo titolo principale e accessibilità da tastiera.
Solo il frontend Mac è stato ricreato, healthy: container `204efd40d3eb`, immagine
`7dd0752ff823`. Il CSS effettivamente servito include la correzione del focus.
Core, catalogo, Qdrant ed embedding sono invariati. Per annullare soltanto questo
follow-up, usare nei comandi di rollback sopra il tag
`thothii-frontend:before-login-focus-20260913`.
## Follow-up: comando Sessione unico e bordi completi
Il pulsante Session/Sessione sostituisce New session e Return to session. Una
sessione aperta non finalizzata/archiviata, o una creazione già inviata in attesa
di completamento, viene conservata e riportata in primo piano. Nessun reset,
nuova ripresa o ricreazione della connessione eventi. In assenza di sessione in
corso si prepara una nuova domanda attraverso il percorso esistente: controlli
di contesto/preprocessing, protezione delle modifiche amministrative, prewarm e
focus del campo. La sessione viene creata solo all'invio della domanda. Un pannello
documentale non ripreso può essere riaperto dalla relativa voce nella lista.
I tab ricevono 3px aggiuntivi per lato orizzontale e 3px verticali, con altezza
minima 38px e crescita per le etichette italiane su due righe. Tutti i bordi sono
da 1px e dello stesso colore: grigio inattivo, rosso attivo. Rimossi il bordo
del contenitore e il margine negativo; 4px separano i tab. Questa richiesta
sostituisce la precedente rimozione del bordo inferiore.
Suite frontend completa: 757 test in 90 file; Playwright: 15 scenari. Superati
anche typecheck, controllo di 1683 traduzioni italiane/1703 riferimenti statici
e build Docker. Coperti ritorno senza seconda ripresa, clic durante creazione,
assenza di creazione prima dell'invio, bozze amministrative e del composer,
geometria/bordi EN/IT in chiaro e scuro e layout mobile/embedded. Le fixture
bloccano le chiamate reali, incluso il prewarm. Screenshot italiani verificati.
Impeccable ha guidato la spaziatura e la conservazione dello stato accessibile.
Il rollback di questo follow-up usa il tag
`thothii-frontend:before-session-navigation-20260913` con i comandi sopra.
Frontend Mac aggiornato e healthy: container `2844778d311c`, immagine `d58dccfee3f9`.
Core e servizi dati rimangono quelli registrati sopra; nessun deploy Omics/PSD.
@@ -0,0 +1,77 @@
# Lettura di Evidence e Memory
Data: 13 settembre 2026. Richiesta: eliminare il muro di testo nelle regole,
usare la larghezza disponibile, fornire Memory finte per la verifica grafica e
sostituire i pulsanti Copia con l'icona a due fogli.
## Intervento
Rimossi il limite di 75ch dal dettaglio Evidence e quello di 72ch dai campi Memory.
La variante di lettura Knowledge elimina anche il limite interno dei paragrafi
Markdown, mantenendo il comportamento responsive del contenitore. Gli altri
visualizzatori non cambiano impaginazione. Codice e identificatori lunghi possono
andare a capo quando necessario, senza allargare la pagina.
Per paragrafi di almeno 360 caratteri, la variante introduce separazioni visive
dopo almeno 180 caratteri, cercando punti, punti e virgola, punti interrogativi
o esclamativi nel testo ordinario. Non divide dentro codice inline, enfasi o link;
elenchi, titoli, paragrafi brevi e blocchi SQL già strutturati rimangono intatti.
La spaziatura tra paragrafi è 1,25em. Si tratta di un intervento sul rendering
Markdown, non di una riscrittura o un salvataggio dei documenti. Nessuna conoscenza
di dominio viene generata dal visualizzatore.
Ambito, Provenienza e Regola occupano l'intera larghezza del dettaglio. Gli estratti
di provenienza interpretano Markdown, senza mostrare letteralmente asterischi e
backtick. Copia usa l'icona Lucide Copy con nome accessibile e tooltip; il percorso
completo viene copiato senza trasformazioni e gli errori restano annunciati.
Impeccable ha guidato la leggibilità, la separazione dei paragrafi e l'accessibilità;
la larghezza piena segue la richiesta esplicita dell'utente anche oltre la misura
di lettura ordinaria del design system.
## Memory simulate
In **Administration → Memory → Formatting examples**, oppure **Amministrazione →
Memory → Esempi di formattazione**, sono disponibili quattro schede FAKE:
- chiarimento di dominio con una regola lunga;
- regola SQL con titoli, elenchi e identificatori;
- domanda risolta con SQL dimostrativo;
- errore spiegato con paragrafi distinti.
La sezione si apre automaticamente se l'archivio reale è vuoto. Gli esempi sono
fixture frontend in `frontend/src/shell/memoryFormattingExamples.ts`, separate
dai risultati dell'archivio e dai suoi conteggi. Riutilizzano il lettore reale,
ma non offrono Edit/Delete/Save, non hanno dipendenze o link a schede reali e non
vengono inviati ad alcuna API Memory. Nessun inserimento PostgreSQL, indicizzazione
Qdrant, consolidamento Evidence o invio al modello è avvenuto. I nomi demo e gli
identificatori non sono riferimenti al DWH operativo.
Si possono richiudere senza cancellazioni. Una futura rimozione degli esempi è
una modifica del frontend, non una pulizia dei dati. New card dopo un esempio
apre una scheda vuota, senza copiarne il contenuto simulato.
## Verifiche e consegna locale
- 762 test frontend in 91 file, tutti superati.
- 18 scenari Playwright, inclusi reader a 390, 1280 e 2400 px, tema scuro,
assenza di overflow, larghezza dei paragrafi e separazione effettiva.
- Test di conservazione di parole, codice e destinazioni dei link, struttura
Markdown e comportamento invariato dei visualizzatori ordinari.
- Clipboard: contenuto copiato esatto, nome accessibile e fallimento gestito.
- Fixture Memory: nessuna richiesta di scrittura o caricamento di una falsa
scheda dal server; nessuna azione di modifica disponibile.
- Typecheck, controllo di 1686 traduzioni italiane/1707 riferimenti statici e build
Docker superati. Screenshot desktop, desktop largo e mobile scuro controllati.
Il Mac serve la nuova variante CSS. Solo frontend è stato ricreato, container
`fc4286b5406d`, immagine `cbe0fe0dce55`, healthy. Core `3c3c0739b9af`, catalogo
`89755e439dc7`, Qdrant `52ebd5c47955` ed embedding `7f690e534eda` restano invariati
e healthy. Non è stato eseguito un deploy Omics o PSD.
Rollback locale, senza toccare altri servizi o volumi:
```bash
docker image tag thothii-frontend:before-knowledge-reading-20260913 thothii-frontend:local
bash /private/tmp/thothii-memory-preview.sh up -d --no-deps --no-build --wait frontend
```
@@ -0,0 +1,52 @@
# Tipografia condivisa per Memory ed Evidence
## Modifica
I lettori mescolavano etichette da 14px, sezioni da 16px e Markdown con una
gerarchia indipendente. I blocchi di codice applicavano una riduzione relativa
anche al carattere già ridotto del contenitore.
La regola condivisa in `frontend/src/index.css`, documentata in `DESIGN.md`, usa:
- Manrope per titolo, sezioni, paragrafi, elenchi e provenienza.
- Titolo della scheda: 24px, peso 600, interlinea 1.3.
- Titoli dei campi: 20px, peso 600, interlinea 1.4, 8px prima del contenuto.
- Testo narrativo: 16px, peso 400, interlinea 1.65.
- Sottotitoli Markdown interni: 16px, peso 600; non competono con il titolo del
campo. I livelli semantici originali sono conservati.
- Metadati: 14px; codice, percorsi e identificatori: monospaziato a 14px, senza
riduzioni cumulative nei blocchi.
- Sezioni distanziate di 24px; paragrafi consecutivi distanziati di 20px.
L'uso della skill Impeccable ha guidato la gerarchia a ruoli fissi e la scelta di
un'unica famiglia, riutilizzando quella già distribuita dall'applicazione.
Rimane valida la richiesta esplicita di occupare tutta la larghezza disponibile:
nessun limite di riga in caratteri e nessuna riduzione dei font su mobile.
Controlli, indici degli archivi e altri lettori non ricevono questa nuova scala.
Documenti originali e schede salvate non sono stati riscritti; gli esempi FAKE
restano esclusi da salvataggio, indicizzazione e uso da parte del modello.
## Verifiche
- Typecheck frontend e build Docker completati.
- 762 test Vitest su 91 file e 18 scenari Playwright superati.
- Controlli CSS calcolati a 390, 1280 e 2400px: famiglia, dimensioni, pesi,
interlinea, margini, codice inline/fenced e sottotitoli Markdown da h1 a h6.
- Ispezione delle schermate Evidence in light e Memory in dark, anche mobile.
- Nessun overflow orizzontale; nessuna scrittura API negli scenari FAKE.
- Catalogo i18n: 1686 messaggi italiani, 1707 riferimenti statici verificati.
## Installazione locale
Ricreato solo il frontend del progetto Docker `thothii-18998cca7b0a`:
container `87fca0dc5e19`, immagine `19f1704b6bb2`, stato healthy.
Il CSS servito da `http://127.0.0.1:8080` contiene le nuove regole condivise.
Core, PostgreSQL, Qdrant ed embedding conservano i container precedenti.
Nessuna modifica alla configurazione full/en, a Omics o al server PSD.
Ripristino della precedente immagine locale:
```bash
docker image tag thothii-frontend:before-knowledge-typography-20260913 thothii-frontend:local
bash /private/tmp/thothii-memory-preview.sh up -d --no-deps --no-build --wait frontend
```
@@ -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.
@@ -0,0 +1,125 @@
# Integrazione della revisione grafica con full/embedded
Data: 2026-09-13. Ambito autorizzato: integrazione locale e aggiornamento del Mac,
senza push, merge in `main` o deploy sul server PSD.
## Causa della regressione
La revisione full/embedded `d8a29bfb` e il ramo `codex/ui-visual-review` partivano
entrambi da `2d1b714e`. La prima build full è stata eseguita dal primo ramo senza
integrare i sette commit grafici già presenti nell'immagine installata sul Mac.
La corrispondenza è verificata anche sulle immagini: il backup
`thothii-frontend:before-full-shell-20260913` e
`thothii-frontend:visual-review-20260912` identificano entrambi `7dceaafcd9ee`.
Commit recuperati, in ordine:
| Commit | Modifica |
| --- | --- |
| `c8d276dd` | Manrope locale, scala tipografica condivisa, leggibilità delle pagine operative |
| `eed398e5` | Marchio ThothII a 48 px nel Core e 32 px nella sidebar |
| `8c819968` | Indicatori del catalogo dopo le rispettive etichette |
| `803e9e92` | Ricalcolo dell'altezza del campo domanda dopo la riapertura del Core |
| `e088abd6` | Allineamento degli stati alla griglia del riepilogo |
| `5af44081` | Margini e assi interni dei blocchi Database |
| `a59624a6` | Allineamento del pannello del contesto e rimozione delle spiegazioni ridondanti |
## Criteri di riconciliazione
- Conservati traduzioni, protezione delle bozze, autenticazione, gestione delle
sessioni e isolamento dell'adapter Omics introdotti dalla revisione full.
- Recuperati font locale, classi e geometria approvati nel ramo grafico.
Impeccable, registro product, ha guidato la verifica della coerenza; nessun
ridisegno delle pagine o nuovo accorciamento dei loro titoli.
- Il reset e i token CSS restano circoscritti al mount di ThothII e ai suoi popup:
la revisione grafica non reintroduce un reset del documento del portale.
- Conservati tema, traduzioni e stato di AG Grid; le colorazioni degli avvisi
rimangono leggibili nei due temi.
- Tradotte le nuove etichette e le spiegazioni introdotte dalla revisione grafica.
- Il mock di ResizeObserver dei test ora emette le misure degli elementi osservati,
come richiesto anche dal ridimensionamento del campo domanda recuperato.
- I test visuali configurano esplicitamente full oppure embedded. La simulazione
embedded fornisce le preferenze del portale e il suo reset del margine del body;
ThothII non deve applicare quel reset al documento esterno.
## Verifiche
| Controllo | Esito |
| --- | --- |
| Suite frontend completa | 755 test passati, zero fallimenti |
| Playwright visuale/interazione | 11 scenari passati, zero saltati o instabili |
| Larghezze della revisione grafica | 390, 649, 768, 1280 e 1600 px |
| Combinazione lingua/tema/full | EN/IT, chiaro/scuro, persistenza al reload e margini simmetrici a 390 e 1280 px |
| Caricamento font | Verificata una font face Manrope Variable effettivamente caricata |
| Embedded | Nessun header ThothII, contenimento sotto header e sidebar del portale simulato |
| Cataloghi | 1681 messaggi italiani, 1706 riferimenti statici; nessuna chiave mancante o interpolazione incoerente |
| Typecheck e build | Superati, inclusa la regressione sul CSS compilato prima della build |
| Documentazione | Build MkDocs strict superata |
Le verifiche Playwright usano dati sintetici e bloccano tutte le richieste API;
gli scenari non avviano modelli reali né modificano dati dell'installazione.
Gli screenshot sono in `frontend/test-results/visual-review/`, ignorati da Git.
Backend, harness e Omics non sono modificati da questa integrazione; le loro
verifiche precedenti restano nel rapporto full-shell. Non viene rivendicata una
nuova verifica end-to-end sul portale di produzione.
La verifica indipendente dell'integrazione di CSS, shell e reset non ha segnalato
rilievi aperti. Il controllo della pagina reale ha inoltre rilevato l'etichetta
predefinita «Administration» non tradotta: è stata corretta nel componente condiviso
e coperta dallo scenario EN/IT. Nessun nuovo testo JSX o attributo di accessibilità
non tradotto è stato introdotto dal merge, secondo il confronto statico con `d8a29bfb`.
## Aggiornamento e rollback locali
Contesto di build: `/Users/mp/projects/ThothII`; launcher dell'installazione:
`/private/tmp/thothii-memory-preview.sh`. Il launcher termina con l'override
`/private/tmp/thothii-context-a.compose.yaml`, che seleziona questo checkout.
Non applicare l'override della precedente revisione isolata: selezionerebbe di nuovo
il worktree senza l'integrazione full/embedded.
Comandi usati per costruire e sostituire il solo frontend:
```sh
bash /private/tmp/thothii-memory-preview.sh build frontend
bash /private/tmp/thothii-memory-preview.sh up -d --no-deps --no-build --wait frontend
```
Configurazione Mac conservata: modalità `full`, lingua predefinita `en`.
Aggiornamento completato alle 12:54 UTC: immagine frontend `605a6e6bba68`, container
`9489a5765f7b`, stato healthy. Core (`3c3c0739b9af`), catalogo (`89755e439dc7`),
Qdrant (`52ebd5c47955`) ed embedding (`7f690e534eda`) conservano gli stessi container,
immagini e tempi di avvio rilevati prima dell'aggiornamento; sono tutti healthy.
Il controllo dal browser usa l'accesso locale già presente, senza logout, modifica
del catalogo o avvio di una sessione del modello. Sul viewport reale da 771 px:
titolo Manrope Variable a 24 px, margini full sinistro/destro di 24 px, tre blocchi
Database con identica posizione x=45 e larghezza 681 px; nessun overflow della pagina.
Verificati italiano e tema scuro; ripristinati inglese e tema chiaro.
L'immagine prima del merge è conservata come
`thothii-frontend:before-visual-shell-merge-20260913` (`781650644192`). Per ripristinare
quella versione full precedente, senza toccare Core o i volumi:
```sh
bash /private/tmp/thothii-memory-preview.sh \
-f /Users/mp/projects/ThothII/deploy/compose.visual-shell-rollback.yaml \
up -d --no-deps --no-build --wait frontend
```
Quel rollback reintroduce volutamente l'aspetto precedente al recupero grafico.
Per tornare alla versione integrata usare il launcher senza l'override di rollback.
Le immagini della revisione grafica isolata rimangono disponibili, ma non includono
le nuove funzionalità full/embedded: non sono il rollback equivalente di questa consegna.
## Conservazione del lavoro locale e prevenzione
Le tre modifiche documentali locali preesistenti sono state protette nello stash
`436d59225869aa3d93ee04077c566d371bde4099`. I due rapporti erano identici alle versioni
del ramo grafico; tutte le aggiunte di PROJECT_STATE erano già contenute nel merge.
Non è stato necessario riapplicarle o sovrascrivere le versioni integrate. Lo stash
è conservato come ulteriore copia recuperabile.
Prima di un successivo rebuild, confrontare il ramo corrente con gli altri worktree
attivi e controllare il contesto di build effettivo del launcher. Un'immagine locale
può provenire da un ramo laterale più recente del checkout principale. Il passaggio
delle sole suite funzionali non certifica che la build contenga la revisione grafica
già accettata: verificare anche l'ascendenza Git e i test visuali recuperati qui.
@@ -0,0 +1,73 @@
# Dialog di sessione: rilascio server del 14 settembre 2026
Aggiornamento autorizzato dall'utente ed eseguito alle 18:09 CEST. Il solo frontend
è stato ricreato; core e Omics web mantengono ID e timestamp di avvio precedenti.
La modifica comprende dialog di sessione più ampi e contenuti nella vista,
conferme sopra e sotto le form, e l'etichetta To Administration / Vai
all’Amministrazione. Le superfici amministrative e il workflow restano invariati.
## Provenienza e configurazione
- Base sorgente: `b1723c34`, più le modifiche frontend della working tree verificate.
- Checkout di build: `/home/chirone/Thoth`; i 32 file frontend interessati sono
stati allineati anche in `/srv/thothii-v2/source/ThothII`, conservando i precedenti.
- Immagine: `thothii-v2-frontend:b1723c34-session-dialogs-20260914`.
- Image ID: `sha256:d2ed3dec42f8a56536ebbc8d74f13892d4c42c9a7f2fb67c476ff39f43fe7af9`.
- Container: `becadeb12b5d9bd380700813e1f489e7f5674575b98ea0d00c6b7d751c890278`.
- Avvio: `2026-09-14T16:09:12.835900768Z`.
- Il solo campo `services.frontend.image` dell'override operativo
`/srv/thothii-v2/operator/compose.portal-upstream.yaml` è fissato al nuovo tag.
Il tag condiviso in `operator.env` non è cambiato: core e job mantengono
`49333a2d-session-memory-fix`. I prossimi aggiornamenti frontend devono aggiornare
esplicitamente questo campo, oppure ripristinarne l'interpolazione condivisa.
- Nessuna migrazione, modifica di dati, credenziali, modello o auth.
## Verifiche
Prima della distribuzione: 778 test frontend, cinque scenari browser responsive
(320/390/844/1280/1440 px, full e embedded), typecheck, traduzioni, build Vite e
Docker. Smoke della configurazione embedded montata nella nuova immagine superato.
Dopo la distribuzione:
- frontend healthy, core e Omics web invariati e healthy;
- `nginx -t` e reload Omics riusciti;
- config embedded e asset nuovi HTTP 200 tramite nginx Omics locale con Host reale;
- JavaScript `index-inK74FWS.js` contiene entrambe le nuove etichette;
- CSS `index-4WPDUJu7.css` contiene le regole `.thot-session-dialog`;
- manifest nuovo raggiungibile dal container Omics; trascorso il TTL di 30 secondi;
- API `/me` senza login ancora HTTP 403;
- `tht doctor --json`: `ok: true`, 13/13 controlli superati.
I test browser usano dati simulati; l'accettazione della resa nella sessione
Omics autenticata dell'utente richiede di ricaricare la pagina. Non sono state
create sessioni reali né invocati modelli per il collaudo.
## Backup e rollback
Directory protetta: `/srv/thothii-v2/backups/20260914-session-dialogs` (0700).
Contiene override precedente, archivi sorgente prima/dopo, patch, inventario dei
container e checksum verificati. L'immagine precedente è conservata anche come
`thothii-v2-frontend:before-session-dialogs-20260914`.
Il launcher `compose.sh` nel backup conserva esattamente progetto, directory,
env file e ordine dei cinque file Compose della distribuzione. Rilascio eseguito:
```bash
sudo /srv/thothii-v2/backups/20260914-session-dialogs/compose.sh \
up -d --no-deps --no-build --wait --wait-timeout 90 frontend
```
Rollback applicativo, dopo aver verificato che non ci siano release successive:
```bash
sudo cp -p /srv/thothii-v2/backups/20260914-session-dialogs/compose.portal-upstream.yaml \
/srv/thothii-v2/operator/compose.portal-upstream.yaml
sudo /srv/thothii-v2/backups/20260914-session-dialogs/compose.sh \
up -d --no-deps --no-build --wait --wait-timeout 90 frontend
sudo docker exec omics_portal-nginx-1 nginx -t
sudo docker exec omics_portal-nginx-1 nginx -s reload
```
Il ripristino dei sorgenti è separato: gli archivi conservano esattamente i file
interessati. Un rollback grafico non richiede ripristino di database o indici.
@@ -0,0 +1,148 @@
# Correzione input sessione embedded e Memory vuota
## Stato
**Rilascio operativo eseguito il 14 settembre 2026 alle 17:18 CEST**, dopo
l'autorizzazione esplicita del proprietario al riavvio. Core e frontend sono healthy;
le nuove ammissioni sono riaperte (`active: false`, `admissions: 0`). Non risultavano
processi Pi RPC attivi prima del fermo. L'inventario resta identico: una sessione
`open` e una `closed`, entrambe non archiviate.
Il checkout operativo `/srv/thothii-v2/source/ThothII` è stato aggiornato in
fast-forward al commit Gitea `d6cdffea629daf92cae8392eb1ebb3bb6f275f55`.
## Cause e correzioni
- La nuova `.thot-host` usa `100dvh`; Omics limita invece `#root` allo spazio
sotto il topbar e ne nasconde l'overflow. La prova browser riproduce controlli
che terminano a 821 px con il contenitore che termina a 782 px. La shell
embedded ora ha `max-height: 100%`, così rispetta il contenitore; il fallback
alla viewport e il contratto `--thoth-app-height` restano utilizzabili.
- Nell'installazione reale PostgreSQL contiene zero Memory Card e la collection
`psd-clinical-memory` zero punti, senza vettore sparse BM25. Il workflow
interrogava comunque embedding/Qdrant e trasformava `BM25 collection
configuration mismatch` in `memory_unavailable`, status 503. Il recupero ora
restituisce `[]` dopo aver verificato in PostgreSQL che l'archivio è vuoto.
Gli errori dell'archivio autorevole continuano a propagarsi. `memory rules`
calcola il vettore soltanto se serve e lo condivide fra le due famiglie.
Nessuna migrazione, modifica di card, indice, DWH o autenticazione è necessaria.
## Verifiche eseguite
- Riproduzione live: `tht memory search` restituiva 503.
- Confronto delle immagini attuale/candidata, con PostgreSQL e Qdrant reali e
montaggi read-only: attuale exit 1/status 503; candidata exit 0/`[]`. La
configurazione diagnostica non contiene credenziali DWH e non interroga il DWH.
- 54 test Memory superati, 1 skipped, 2 deselected secondo la configurazione
pytest; inclusi test PostgreSQL/Qdrant reali e regressione CLI archivio vuoto.
- 90 test frontend superati: shell host, composer, creazione e gestione sessioni.
- 5 scenari Playwright superati: sessione attiva embedded a 390/1280 px con
input multilinea e apertura del dialogo di arresto; composer full dopo
navigazione amministrativa; geometria host con header/rail.
- TypeScript, build frontend, Ruff sui file Python modificati, `git diff --check`
e scansione layout superati. Entrambe le build Docker completate;
smoke della configurazione frontend superato.
- Screenshot verificati in `frontend/test-results/visual-review/`.
## Immagini distribuite e controlli server
Le immagini candidate già collaudate sono quelle ora in esecuzione. Container core
`1e71b0abd5aa` e frontend `45387534e8ad` avviati rispettivamente alle 15:18:43 e
15:18:49 UTC. Catalogo, Qdrant, embedding e Omics web non sono stati ricreati.
Nginx Omics è stato verificato e ricaricato per risolvere gli indirizzi aggiornati.
| Immagine distribuita | ID |
| --- | --- |
| `thothii-v2-core:49333a2d-session-memory-fix` | `sha256:ff4c435abd67c57e1e91e6e560dae73e67350ca499a5aedca3ffa517b9f59ee0` |
| `thothii-v2-frontend:49333a2d-session-memory-fix` | `sha256:35933317769f3e12953f3b144d51f8fc2e7e9f7c4830eba80cc626f757f5bf0d` |
Controlli dopo il riavvio:
- `memory search` e `memory solved-search` nel core distribuito: exit 0, `[]`.
- `workflow-doctor`: `ready: true`, un workspace; inventario sessioni invariato.
- HTTP `/health`: `status: ok`; tutti i servizi richiesti healthy.
- Omics serve config embedded/en e asset con HTTP 200: `index-BpY9Lzwz.js`,
`index-BhJrKSGn.css`; verificata nel CSS la regola di altezza corretta.
- `/datamart-builder/api/me` senza login continua a rispondere 403.
- `nginx -t` superato; reload completato; TTL manifest Django trascorso.
- `tht status`: exit 0; `tht doctor --json`: `ok: true`, 13/13 controlli passati.
- Nessun errore di avvio rilevato nei log core.
Backup protetto in `/srv/thothii-v2/backups/20260914-session-memory-fix`
(directory 0700): operator.env, descriptor e override precedenti, archivio di data,
workspace-registry e Pi creato a core fermo, dump logico PostgreSQL e snapshot nativo
Qdrant. Elenco tar e `pg_restore --list` validati; tutti gli SHA256 verificati.
Le immagini precedenti restano anche con tag `before-session-memory-fix-20260914`.
Nessuna migrazione o modifica al DWH. Il rollback normale resta applicativo.
## Comandi di rilascio e diagnostica
Il seguente launcher conserva progetto, env file e ordine degli override. Le
ammissioni sono state bloccate e il core fermato prima del backup:
```bash
thoth_fix_compose() {
sudo docker compose --project-name thothii-7f901b48fe35 \
--project-directory /srv/thothii-v2/source/ThothII \
--env-file /srv/thothii-v2/operator/operator.env \
-f /srv/thothii-v2/source/ThothII/compose.yaml \
-f /srv/thothii-v2/source/ThothII/deploy/compose.server.yaml \
-f /srv/thothii-v2/source/ThothII/deploy/compose.git-ssh.yaml \
-f /srv/thothii-v2/operator/compose.portal-upstream.yaml \
-f /srv/thothii-v2/source/ThothII/deploy/psd-server-v2/generated/compose.models.yaml "$@"
}
thoth_fix_compose exec -T core node /app/backend/dist/operator-command.js maintenance-activate
thoth_fix_compose exec -T core node /app/backend/dist/operator-command.js maintenance-status
thoth_fix_compose stop --timeout 45 core
```
Dopo il backup è stato aggiornato esclusivamente `THTII_RELEASE_IMAGE_TAG` in
`/srv/thothii-v2/operator/operator.env` a `49333a2d-session-memory-fix`, preservando
altri valori, proprietario e permessi. Avvio e reload eseguiti:
```bash
thoth_fix_compose up -d --no-deps --no-build core frontend
sudo docker exec omics_portal-nginx-1 nginx -t
sudo docker exec omics_portal-nginx-1 nginx -s reload
```
Il CLI legge i segreti soltanto con l'UID proprietario (10001). L'invocazione
come root viene rifiutata dal controllo di proprietà; nessun file segreto è stato
modificato. Per la diagnostica è stata usata una configurazione Docker temporanea
vuota, evitando la directory `/root/.docker` inaccessibile a quell'UID:
```bash
sudo install -d -m 0700 -o 10001 -g 1006 /tmp/thoth-owner-docker
thoth_fix_cli() {
sudo env DOCKER_CONFIG=/tmp/thoth-owner-docker \
setpriv --reuid=10001 --regid=1006 --groups=1006,1014,988 \
/usr/local/bin/tht --installation \
/srv/thothii-v2/source/ThothII/deploy/psd-server-v2/thothii-installation.yaml "$@"
}
thoth_fix_cli status
thoth_fix_cli doctor --json
```
Riapertura dopo i controlli runtime:
```bash
thoth_fix_compose exec -T core node /app/backend/dist/operator-command.js maintenance-deactivate
```
Rollback applicativo: ripristinare l'operator.env protetto, ricreare solo
core/frontend con lo stesso launcher, verificare e ricaricare nginx. Per riallineare
anche i sorgenti preparare il revert del solo commit `d6cdffea`, preservando le
modifiche successive. Non occorre ripristinare
PostgreSQL o indici per un rollback di queste due correzioni.
## Accettazione interattiva
Le prove browser automatiche su input e arresto sono passate prima del rilascio;
la distribuzione degli asset corretti è verificata attraverso il proxy reale.
Resta da confermare il comportamento nella sessione autenticata del proprietario,
ricaricando la pagina Omics. Il collaudo non ha creato sessioni workflow reali né
invocato un modello generativo; non sostituisce l'accettazione interattiva completa
Omics/IdP documentata nella matrice di autenticazione.
@@ -0,0 +1,86 @@
# Accettazione di shell e autenticazione
Questa matrice è un gate di rilascio, non una dichiarazione che le prove server
siano già state eseguite. Registrare ambiente, data, SHA ThothII/portale, immagini,
modalità effettive e risultato di ogni caso. Usare account di prova autorizzati;
non incollare token, cookie, password o dati clinici nelle evidenze.
## Configurazione prima della prova
- Full/local: descrittore `shell.mode: full`, default en, `auth.yaml` local;
`AUTH_MODE` non impostato. Scegliere browser/origin coerenti con `publicUrl`.
- Full/OIDC: full, configurazione OIDC valida e callback esatta
`PUBLIC_URL/api/auth/oidc/callback`; `AUTH_MODE` non impostato.
- Embedded/Omics: embedded/omics-portal, core upstream senza auth.yaml/runtime
projection, sessione portale e capability; API attraverso il solo proxy fidato.
- Config pubblico caricato prima del modulo React, no-store e senza segreti;
connessioni private del core non raggiungibili da client non fidati.
## Full, senza documento Omics
| Prova | Risultato atteso |
| --- | --- |
| Primo accesso con preferenze browser assenti | Header ThothII; inglese e light; nessun errore per elementi Omics mancanti |
| Login locale valido/errato | Identità verificata nel primo caso; errore generico e nessun contenuto protetto nel secondo |
| Remember me | Sessione persistente secondo TTL local; senza Remember me cookie non persistente |
| OIDC con provider raggiungibile | Redirect, callback validata, nome e permessi dall'identità verificata |
| OIDC con claim gruppi invalido | Login rifiutato; nessuna informazione sensibile nel browser |
| OIDC con gruppi validi ma non mappati | Nessun ruolo, accesso alle route protette negato |
| Nome → Log out | Sessione ThothII revocata; una nuova richiesta protetta con quella sessione non accede |
| Nuovo login OIDC dopo logout | Può riusare SSO provider; nessuna promessa di logout globale |
| Disabilitazione/ruolo/password/logout-all locale | La sessione precedente viene invalidata al controllo server |
| Cambio lingua e tema | Etichette aggiornate, preferenze ricordate, leggibilità anche di popup e griglie |
| Fullscreen, Esc, rifiuto del browser | Stato/icona coerenti; errore visibile su rifiuto, nessun falso fullscreen |
| Schermo stretto/largo | Margini simmetrici almeno 20px; header adattivo; nessuna rotellina amministrativa |
## Embedded, dal portale reale
| Prova | Risultato atteso |
| --- | --- |
| Utente già autenticato e autorizzato → Datamart Builder | Nessun secondo login e un solo header, quello Omics |
| Utente senza capability o sessione scaduta | Pagina/API protette rifiutate, non UI autenticata ottenuta da eventi DOM |
| `/datamart-builder/api/me` autorizzato | Issuer `portal`, subject stabile, ruoli attesi; session/CSRF ThothII null |
| Utente normale vs amministratore | Azioni amministrative protette server-side; flag admin non controllabile dal client |
| Header identità inventati dal client, anche admin | Non concedono accesso né elevazione; il proxy usa soltanto la verifica Django |
| Richiesta cross-origin a un'operazione di prova | Rifiutata senza modificare dati; testare con risorse fittizie in ambiente isolato |
| IT/EN selezionato prima dell'apertura | Locale iniziale uguale a quello Django confermato |
| Cambio lingua dal form Omics | Reload normale con CSRF/next; selezione sessione conservata, nessuna generazione automatica |
| Cambio tema con menu/form aperti | UI, popup e griglia seguono Omics, senza controlli locali duplicati |
| Fullscreen dall'header e uscita con Esc | Stato reale sincronizzato anche dentro ThothII |
| Logout Omics e ritorno in una seconda scheda | Al ricontrollo `/me` lo stato protetto viene rimosso; rientro tramite Omics |
| Riconnessione SSE dopo scadenza | Verifica accesso prima di riprendere; nessun aggiramento del proxy |
| 403 su una sola operazione con `/me` ancora valido | Errore operativo, non logout indiscriminato |
| Contesto host mancante/tema invalido | Errore d'integrazione; niente fallback full o login autonomo |
| API, config e asset dopo rebuild | Prefissi corretti, config no-store, manifest aggiornato dopo la sua cache di 30 s |
Le prove con header inventati devono attraversare l'ingresso pubblico protetto,
non certificare la sicurezza inviando header direttamente al core che per
contratto si fida del proxy. L'isolamento di rete del core va verificato a parte.
Non promettere che il logout chiuda immediatamente tutti gli SSE già aperti: il
proxy autorizza all'apertura e i ricontrolli avvengono sulle nuove richieste.
## Continuità del workflow, entrambe le modalità
| Prova | Risultato atteso |
| --- | --- |
| Nuova sessione con UI italiana/inglese | `interaction_language` acquisita alla creazione; domande e scelte nella lingua salvata |
| Cambio UI dopo creazione e ripresa | La lingua della sessione resta invariata |
| Vecchio manifest senza lingua | Prima ripresa fissa la lingua workspace secondo il criterio di compatibilità |
| Bozza/modifica amministrativa e navigazione | Conferma prima della perdita; annullamento conserva la modifica |
| Reload con selezione esistente | Riapre documenti senza avviare Pi/generazione automaticamente |
| Cambio utente | Nessuno stato protetto della precedente identità riutilizzato |
| SQL, identificatori, Evidence e Memory | Contenuti originali non tradotti/riscritti dal cambio UI |
## Copertura automatica e limiti
Frontend: `AuthGate.test.tsx`, `authState.test.ts`, `OmicsPortalAdapter.test.ts`,
`AppShell.host.test.tsx`, test di stream/sessioni e
`e2e/ui-visual-review.spec.ts`. Backend: test di auth, principal, route e
isolamento della configurazione. CLI: normalizzazione shell e proiezioni.
I test Omics isolati sono descritti nel suo `docs/thothii-integration.md`.
I test automatici con API/IdP simulati non sostituiscono callback reali,
sessioni Omics, permessi di rete, TLS, cookie reali e interazioni fra schede sul
server. Prima di chiudere il rilascio indicare esplicitamente prove superate,
non eseguite e motivi del rinvio. Procedure e rollback:
[shell e deploy](../operations/shell-and-localization.md).
@@ -409,7 +409,7 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
await page.goto(stack.publicUrl);
await signInAsAdmin(page);
await expectCurrentNavigation(page, "New session");
await expectCurrentNavigation(page, "Session");
await expectSelectedSessionScopeTab(page, "My sessions");
await page.getByRole("tab", { name: "All sessions", exact: true }).click();
await expectSelectedSessionScopeTab(page, "All sessions");
@@ -808,7 +808,7 @@ test("Workspace and Pi management share the centered work-area panel without cov
.getByRole("button", { name: "Close Pi management" })
.click();
await expect(piTrigger).toBeFocused();
await expectCurrentNavigation(page, "New session");
await expectCurrentNavigation(page, "Session");
}
await page.setViewportSize({ width: 720, height: 800 });
+1 -1
View File
@@ -39,7 +39,7 @@ test("F1 loop: new question → F1 widget → respond", async ({ page }) => {
await signInLocally(page);
// Focus the composer for a new session.
await page.getByRole("button", { name: "New session", exact: true }).click();
await page.getByRole("button", { name: "Session", exact: true }).click();
// Fill in the question.
await page.getByLabel("New question").fill("quante cardioversioni nel 2024");
+420 -10
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../src/test/workspace-fixtures";
import { memoryFormattingExamples } from "../src/shell/memoryFormattingExamples";
// These tests never forward API requests to an installation or invoke a real model.
const workspace = "visual-fixture";
@@ -21,8 +22,20 @@ const evidencePage = {
git_commands: ["git status", "git diff"] },
};
async function fixtures(page: Page) {
async function fixtures(page: Page, mode: "full" | "embedded" = "full") {
const writes: string[] = [];
await page.route("**/config.js", route => route.fulfill({
contentType: "application/javascript",
body: `window.__THOTHII_CONFIG__ = ${JSON.stringify({ backendBaseUrl: "/api", shell: { mode, defaultLocale: "en" } })};
${mode === "embedded" ? `
document.documentElement.setAttribute("data-bs-theme", "light");
const language = document.createElement("select");
language.className = "omics-language-select";
language.dataset.lang = "en";
language.hidden = true;
document.body.prepend(language);
` : ""}`,
}));
await page.route("http://127.0.0.1:5183/api/**", async route => {
const path = new URL(route.request().url()).pathname.replace(/^\/api/, "");
if (route.request().method() !== "GET") {
@@ -90,13 +103,291 @@ async function inspect(page: Page) {
});
}
for (const width of [390, 1280]) {
test(`session navigation has readiness dot and bounded accordion lists at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1000 });
const writes = await fixtures(page);
const sessions = Array.from({ length: 80 }, (_, index) => ({
id: `nav-${index}`, name: `Session ${index}`, question: `Question ${index}`,
archived: index >= 40, active: false, status: index >= 40 ? "finalized" : "open",
workspace, group: null, author: null, summary: null,
created_at: "2026-09-01T12:00:00Z", updated_at: null,
}));
await page.route("**/api/sessions?*", route => route.fulfill({ json: sessions }));
await page.route("**/api/sessions", route => route.fulfill({ json: sessions }));
await page.goto("/");
await navigation(page);
await page.getByRole("button", { name: "Administration", exact: true }).click();
const workspaceButton = page.getByRole("button", { name: "Workspace", exact: true });
await expect(workspaceButton.getByRole("img")).toHaveAttribute("data-ready", "true");
await expect(workspaceButton).toHaveAccessibleDescription("Workspace readiness: ready");
await expect(page.getByText("Workspace readiness: ready", { exact: true })).toHaveCount(0);
const green = await workspaceButton.getByRole("img").evaluate(el => getComputedStyle(el).backgroundColor);
const active = page.getByRole("button", { name: "Active sessions", exact: true });
const archive = page.getByRole("button", { name: "Archive (40)", exact: true });
await expect(active).toHaveAttribute("aria-expanded", "false");
await expect(archive).toHaveAttribute("aria-expanded", "false");
await expect(page.getByRole("checkbox", { name: "Select all" })).toHaveCount(0);
await expect(page.getByText("Sessions", { exact: true })).toHaveCount(0);
const activeBox = await active.boundingBox();
const archiveBox = await archive.boundingBox();
expect(archiveBox!.y - activeBox!.y - activeBox!.height).toBeLessThan(8);
await page.screenshot({ path: testInfo.outputPath("session-accordions-closed.png"), animations: "disabled" });
for (const name of ["Active sessions", "Archive (40)"]) {
await page.getByRole("button", { name, exact: true }).click();
await expect(name === "Active sessions" ? archive : active).toHaveAttribute("aria-expanded", "false");
await expect(page.locator(".thot-session-accordion__panel:visible")).toHaveCount(1);
const panel = page.getByRole("region", { name, exact: true });
await expect(panel.getByRole("checkbox", { name: "Select all" })).toBeVisible();
await expect(panel).toHaveCSS("overflow-y", "auto");
const dimensions = await panel.evaluate(el => ({ height: el.getBoundingClientRect().height, scroll: el.scrollHeight, client: el.clientHeight }));
expect(dimensions.height).toBeLessThanOrEqual(288);
expect(dimensions.client).toBeGreaterThan(30);
expect(dimensions.scroll).toBeGreaterThan(dimensions.client);
const box = await panel.boundingBox();
expect(box!.y + box!.height).toBeLessThanOrEqual(1000);
await panel.evaluate(el => { el.scrollTop = el.scrollHeight; });
expect(await panel.evaluate(el => el.scrollTop)).toBeGreaterThan(0);
}
await page.screenshot({ path: testInfo.outputPath("session-accordions.png"), animations: "disabled" });
await expect(archive).toBeInViewport();
expect((await inspect(page)).overflow).toBe(0);
// A refetch failure must not keep a stale green signal.
await page.route("**/api/workspaces/*/preprocessing", route => route.fulfill({ status: 503, json: { error: "Fixture unavailable" } }));
await expect(workspaceButton.getByRole("img")).toHaveAttribute("data-ready", "false", { timeout: 15000 });
await expect(workspaceButton).toHaveAccessibleDescription("Workspace readiness: unavailable");
expect(await workspaceButton.getByRole("img").evaluate(el => getComputedStyle(el).backgroundColor)).not.toBe(green);
expect(writes).toEqual([]);
});
}
for (const width of [390, 1280, 2400]) {
test(`knowledge readers use available width and readable paragraphs at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1100 });
const writes = await fixtures(page);
const sample = memoryFormattingExamples[0];
const longEvidence = { ...evidence, applies_to: { concepts: ["Esempio simulato"], tables: ["demo_visuale.identificatore_lungo_per_la_verifica_della_formattazione"], columns: [] },
payload: { rule: sample.detail + "\n\n" + [1, 2, 3, 4, 5, 6].map(level => "#".repeat(level) + " Sottotitolo " + level + "\n\nTesto della sottosezione.").join("\n\n") + "\n\n```sql\nSELECT 1;\n```" }, provenance: { source_file: "source/demo.md", supporting_excerpts: ["**Esempio simulato** con `codice_inline` e un collegamento alla regola.\n\nSecondo paragrafo di provenienza."] } };
await page.route(`**/api/workspaces/${workspace}/evidence/*`, route => route.fulfill({ json: { ...evidencePage, item: longEvidence } }));
await page.goto("/");
await admin(page, "Evidence");
await page.getByRole("button", { name: evidence.title, exact: true }).click();
const evidenceDetail = page.getByRole("region", { name: "Evidence detail" });
const article = evidenceDetail.locator("article");
await expect(article).toHaveCSS("max-width", "none");
const rule = article.locator(".thot-knowledge-prose").first();
await expect(rule.locator(":scope > p")).not.toHaveCount(1);
await expect(rule.locator("p").first()).toHaveCSS("max-width", "none");
await expect(article.locator(".thot-knowledge-title")).toHaveCSS("font-size", "24px");
for (const heading of await article.locator(".thot-knowledge-heading").all()) {
await expect(heading).toHaveCSS("font-size", "20px");
await expect(heading).toHaveCSS("font-weight", "600");
await expect(heading).toHaveCSS("margin-bottom", "8px");
}
const bodyFamily = await rule.evaluate(el => getComputedStyle(el).fontFamily);
expect(bodyFamily).toContain("Manrope");
for (const paragraph of await article.locator("section > p:not(.thot-knowledge-meta), .thot-knowledge-prose p").all()) {
await expect(paragraph).toHaveCSS("font-size", "16px");
await expect(paragraph).toHaveCSS("line-height", "26.4px");
await expect(paragraph).toHaveCSS("font-family", bodyFamily);
}
for (const heading of await rule.locator("h1,h2,h3,h4,h5,h6").all()) {
await expect(heading).toHaveCSS("font-size", "16px");
await expect(heading).toHaveCSS("font-weight", "600");
await expect(heading).toHaveCSS("font-family", bodyFamily);
}
await expect(rule.locator("pre code")).toHaveCSS("font-size", "14px");
await expect(rule.locator("p code").first()).toHaveCSS("font-size", "14px");
expect(await article.evaluate(el => {
const parent = el.parentElement!;
const css = getComputedStyle(parent);
return Math.abs(el.getBoundingClientRect().width - (parent.clientWidth - parseFloat(css.paddingLeft) - parseFloat(css.paddingRight)));
})).toBeLessThan(2);
const copy = article.getByRole("button", { name: "Copy Evidence file path", exact: true });
await expect(copy).toHaveText("");
await expect(copy.locator("svg")).toHaveCount(1);
await expect(article.locator("strong").filter({ hasText: "Esempio simulato" })).toBeVisible();
await article.locator(".thot-knowledge-title").scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("evidence-readable.png"), animations: "disabled" });
expect((await inspect(page)).overflow).toBe(0);
await admin(page, "Memory");
await page.getByRole("button", { name: sample.subject, exact: true }).click();
const memoryDetail = page.getByRole("region", { name: "Memory detail" });
await expect(memoryDetail.getByRole("note")).toContainText("Not saved, indexed or sent to the model.");
const content = memoryDetail.locator(".thot-knowledge-prose").first();
await expect(content.locator(":scope > p")).not.toHaveCount(1);
await expect(content.locator("p").first()).toHaveCSS("max-width", "none");
await expect(content.locator("p").nth(1)).toHaveCSS("margin-top", "20px");
await expect(memoryDetail.locator(".thot-knowledge-title")).toHaveCSS("font-size", "24px");
for (const heading of await memoryDetail.locator(".thot-knowledge-heading").all()) {
await expect(heading).toHaveCSS("font-size", "20px");
await expect(heading).toHaveCSS("font-weight", "600");
await expect(heading).toHaveCSS("font-family", bodyFamily);
await expect(heading).toHaveCSS("margin-bottom", "8px");
}
await expect(content.locator("p").first()).toHaveCSS("font-size", "16px");
await expect(content.locator("p").first()).toHaveCSS("line-height", "26.4px");
await expect(content.locator("p").first()).toHaveCSS("font-family", bodyFamily);
expect(await content.evaluate(el => Math.abs(el.getBoundingClientRect().width - el.parentElement!.clientWidth))).toBeLessThan(2);
await memoryDetail.getByRole("heading", { name: sample.subject, exact: true }).scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("memory-readable.png"), animations: "disabled" });
await expect(memoryDetail.getByRole("button", { name: "Edit card", exact: true })).toHaveCount(0);
expect((await inspect(page)).overflow).toBe(0);
await page.getByRole("button", { name: "Use dark theme", exact: true }).click();
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
await memoryDetail.getByRole("heading", { name: sample.subject, exact: true }).scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("memory-readable-dark.png"), animations: "disabled" });
await expect(content.locator("p").first()).toHaveCSS("line-height", "26.4px");
await page.getByRole("button", { name: memoryFormattingExamples[1].subject, exact: true }).click();
await expect(memoryDetail.getByRole("heading", { name: "Prima del conteggio" })).toHaveCSS("font-size", "16px");
await expect(memoryDetail.getByRole("heading", { name: "Prima del conteggio" })).toHaveCSS("font-family", bodyFamily);
await memoryDetail.locator(".thot-knowledge-title").scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("memory-headings-dark.png"), animations: "disabled" });
await page.getByRole("button", { name: memoryFormattingExamples[2].subject, exact: true }).click();
await expect(memoryDetail.locator("pre")).toHaveCSS("font-size", "14px");
expect((await inspect(page)).overflow).toBe(0);
expect(writes).toEqual([]);
});
}
for (const width of [390, 1280]) {
test(`full shell keeps the visual revision across language and theme changes at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1000 });
const writes = await fixtures(page);
await page.goto("/");
const header = page.locator(".thot-full-header");
await expect(header).toBeVisible();
await expect(header).toHaveCSS("background-color", "rgb(203, 51, 59)");
expect(await header.locator(".thot-full-header__brand span").evaluate(el =>
getComputedStyle(el).color === getComputedStyle(el.closest("header")!).color,
)).toBe(true);
await page.evaluate(() => document.fonts.ready);
expect(await page.evaluate(() => [...document.fonts].some(font =>
font.family.includes("Manrope Variable") && font.status === "loaded",
))).toBe(true);
const layout = await page.locator(".thot-context-layout").boundingBox();
expect(layout!.x).toBeGreaterThanOrEqual(20);
expect(Math.abs(layout!.x - (width - layout!.x - layout!.width))).toBeLessThan(1);
await admin(page, "Database");
await expect(page.getByRole("heading", { name: "Database management", exact: true })).toBeVisible();
await expect(page.locator(".thot-fleet-ledger__header-status")).toHaveCSS("padding-top", "16px");
await page.getByRole("button", { name: /Working context/ }).click();
const fields = page.locator(".thot-context-fields");
const done = fields.getByRole("button", { name: "Done", exact: true });
await expect(done).toBeVisible();
if (width >= 1280) {
const workspaceBox = await fields.getByRole("combobox", { name: "Workspace", exact: true }).boundingBox();
const modelBox = await fields.getByRole("combobox", { name: "Model", exact: true }).boundingBox();
const doneBox = await done.boundingBox();
expect(Math.abs(workspaceBox!.y - modelBox!.y)).toBeLessThan(1);
expect(Math.abs(modelBox!.y - doneBox!.y)).toBeLessThan(1);
expect(doneBox!.x).toBeGreaterThan(modelBox!.x + modelBox!.width);
for (const name of ["My sessions", "All sessions"]) {
await expect(page.getByRole("tab", { name, exact: true })).toHaveCSS("border-bottom-width", "1px");
}
}
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.screenshot({ path: testInfo.outputPath("full-context-light.png"), animations: "disabled" });
await done.click();
await header.getByRole("combobox").selectOption("it");
await expect(page.getByRole("heading", { name: "Gestione database", exact: true })).toBeVisible();
await expect(page.locator(".thot-administration-eyebrow")).toHaveText("Amministrazione");
await expect(page.locator(".thot-context-summary small")).toHaveText("Contesto di lavoro");
await page.getByRole("button", { name: "Usa il tema scuro", exact: true }).click();
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
await expect(header).toHaveCSS("background-color", "rgb(203, 51, 59)");
await page.screenshot({ path: testInfo.outputPath("full-header-dark.png"), animations: "disabled" });
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.reload();
await expect(page.getByRole("heading", { name: "Gestione database", exact: true })).toBeVisible();
await expect(header.getByRole("combobox")).toHaveValue("it");
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
await page.getByRole("button", { name: "Usa il tema chiaro", exact: true }).click();
await header.getByRole("combobox").selectOption("en");
await expect(page.getByRole("heading", { name: "Database management", exact: true })).toHaveCSS("font-size", "24px");
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
expect(writes).toEqual([]);
});
}
test("session tabs have padded labels and matching one-pixel borders on every side", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await fixtures(page);
await page.goto("/?thoth_route=administration%2Fdatabase");
const header = page.locator(".thot-full-header");
for (const locale of ["en", "it"]) {
await header.getByRole("combobox").selectOption(locale);
await expect(page.getByRole("button", { name: locale === "it" ? "Sessione" : "Session", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: /^(New session|Return to session|Nuova sessione|Torna alla sessione)$/ })).toHaveCount(0);
const tabs = page.locator('[role="tablist"]').getByRole("tab");
for (const theme of ["light", "dark"]) {
if (theme === "dark") await header.getByRole("button", { name: locale === "it" ? "Usa il tema scuro" : "Use dark theme", exact: true }).click();
for (const selected of [0, 1]) {
await tabs.nth(selected).click();
await expect(tabs.nth(selected)).toHaveAttribute("aria-selected", "true");
for (const tab of await tabs.all()) {
await expect(tab).toHaveCSS("border-bottom-width", "1px");
const borders = await tab.evaluate(el => {
const css = getComputedStyle(el);
return { widths: [css.borderTopWidth, css.borderRightWidth, css.borderBottomWidth, css.borderLeftWidth],
colors: [css.borderTopColor, css.borderRightColor, css.borderBottomColor, css.borderLeftColor],
paddingX: css.paddingLeft, paddingY: css.paddingTop,
fits: el.scrollHeight <= el.clientHeight };
});
expect(borders.widths).toEqual(["1px", "1px", "1px", "1px"]);
expect(new Set(borders.colors).size).toBe(1);
expect(borders).toMatchObject({ paddingX: "11px", paddingY: "3px", fits: true });
}
}
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath(`session-tabs-${locale}-${theme}.png`), animations: "disabled" });
if (theme === "dark") await header.getByRole("button", { name: locale === "it" ? "Usa il tema chiaro" : "Use light theme", exact: true }).click();
}
}
});
for (const locale of ["en", "it"]) {
test(`login keeps only the main title in ${locale}`, async ({ page }, testInfo) => {
await fixtures(page);
await page.route("**/config.js", route => route.fulfill({ contentType: "application/javascript",
body: `window.__THOTHII_CONFIG__ = ${JSON.stringify({ backendBaseUrl: "/api", shell: { mode: "full", defaultLocale: locale } })};` }));
await page.route("**/api/auth/config", route => route.fulfill({ json: { mode: "local", localLogin: true, oidcLogin: false } }));
await page.route("**/api/me", route => route.fulfill({ status: 401, json: { code: "unauthenticated" } }));
await page.goto("/");
await expect(page.getByRole("heading", { name: locale === "it" ? "Accedi a ThothII" : "Sign in to ThothII" })).toBeVisible();
await expect(page.getByText(locale === "it" ? "Accesso a ThothII" : "ThothII access", { exact: true })).toHaveCount(0);
await expect(page.getByText(locale === "it" ? "Usa l’account della tua installazione per continuare." : "Use your installation account to continue.", { exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath(`login-${locale}.png`), animations: "disabled" });
});
}
test("language selector has no pointer ring and retains keyboard focus in both themes", async ({ page }, testInfo) => {
await fixtures(page);
await page.goto("/");
const language = page.getByRole("combobox", { name: "Interface language" });
for (const theme of ["light", "dark"]) {
if (theme === "dark") await page.getByRole("button", { name: "Use dark theme", exact: true }).click();
await language.click();
await page.keyboard.press("Escape");
await expect(language).toBeFocused();
await expect(language).toHaveCSS("outline-style", "none");
await expect(language).toHaveCSS("box-shadow", "none");
await page.screenshot({ path: testInfo.outputPath(`language-pointer-${theme}.png`), animations: "disabled" });
await page.keyboard.press("Tab");
await page.keyboard.press("Shift+Tab");
await expect(language).toBeFocused();
await expect(language).toHaveCSS("outline-style", "solid");
}
});
for (const route of ["/", "/?thoth_route=administration%2Fdatabase"]) {
test(`Core prompt remains readable after loading ${route}`, async ({ page }, testInfo) => {
const writes = await fixtures(page);
await page.goto(route);
if (route !== "/") {
await navigation(page);
await page.getByRole("button", { name: "Return to session", exact: true }).click();
await page.getByRole("button", { name: "Session", exact: true }).click();
}
const prompt = page.getByRole("textbox", { name: "New question", exact: true });
await expect(prompt).toBeVisible();
@@ -110,6 +401,11 @@ for (const route of ["/", "/?thoth_route=administration%2Fdatabase"]) {
await readable();
await expect(prompt).toHaveCSS("outline-style", "none");
await page.screenshot({ path: testInfo.outputPath("Prompt-focused.png") });
// Full-mode gutters can wrap the empty placeholder on a narrow viewport.
// Compare clearing with that same viewport's initial height, not a single-line constant.
await page.setViewportSize({ width: 390, height: 900 });
await readable();
const emptyHeight = await prompt.evaluate(el => el.clientHeight);
await prompt.fill("First line");
await prompt.press("Shift+Enter");
await prompt.press("a");
@@ -123,14 +419,14 @@ for (const route of ["/", "/?thoth_route=administration%2Fdatabase"]) {
expect(await prompt.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true);
await admin(page, "Database");
await navigation(page);
await page.getByRole("button", { name: "Return to session", exact: true }).click();
await page.getByRole("button", { name: "Session", exact: true }).click();
await expect(prompt).toHaveValue(question);
await readable();
await page.screenshot({ path: testInfo.outputPath("Prompt-multiline-mobile.png") });
await prompt.fill("");
await readable();
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBeLessThanOrEqual(40);
expect(writes).toEqual([]);
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBe(emptyHeight);
expect(writes).toEqual(route === "/" ? ["/runtime/prewarm"] : ["/runtime/prewarm", "/runtime/prewarm"]);
});
}
@@ -233,10 +529,11 @@ test("theme follows the app, not an unrelated OS preference; keyboard tabs and d
await page.emulateMedia({ colorScheme: "dark" });
await page.goto("/");
await expect(page.getByTestId("app-shell")).toBeVisible();
await expect(page.getByRole("banner", { name: "Application header" })).toBeVisible();
await admin(page, "Pi configuration");
const testButton = page.getByRole("button", { name: "Test catalog default", exact: true });
await expect(testButton).toHaveCSS("background-color", "oklch(0.9985 0.0006 17.2)");
await page.evaluate(() => document.documentElement.setAttribute("data-bs-theme", "dark"));
await page.getByRole("button", { name: "Use dark theme", exact: true }).click();
await expect(page.locator(".thot-context-trigger")).toHaveCSS("color", "oklch(0.931 0 90)");
await expect(testButton).toHaveCSS("background-color", "oklch(0.62 0.008 23.2 / 0.3)");
await page.screenshot({ path: testInfo.outputPath("Pi-dark.png") });
@@ -249,22 +546,24 @@ test("theme follows the app, not an unrelated OS preference; keyboard tabs and d
await admin(page, "Memory");
await page.getByRole("button", { name: "New card", exact: true }).click();
await page.getByRole("textbox", { name: "Title", exact: true }).fill("Keep this draft");
await page.getByRole("button", { name: "Return to session", exact: true }).click();
await page.getByRole("button", { name: "Session", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Title", exact: true })).toHaveValue("Keep this draft");
await page.screenshot({ path: testInfo.outputPath("Memory-dark-unsaved.png") });
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Return to session", exact: true }).click();
await page.getByRole("button", { name: "Session", exact: true }).click();
await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible();
expect(writes).toEqual([]);
expect(writes).toEqual(["/runtime/prewarm"]);
});
test("host header and left rail leave the application a bounded working area", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await fixtures(page);
await fixtures(page, "embedded");
await page.goto("/");
await expect(page.getByTestId("app-shell")).toBeVisible();
await expect(page.locator(".thot-full-header")).toHaveCount(0);
// Geometry fixture, not a replacement for acceptance inside the real Omics deployment.
await page.addStyleTag({ content: `
body { margin: 0; }
#root { margin-left: 240px; margin-top: 72px; --thoth-app-height: calc(100dvh - 72px); }
.px-4 { padding-left: 2.25rem !important; padding-right: 2.25rem !important; }
` });
@@ -280,3 +579,114 @@ test("host header and left rail leave the application a bounded working area", a
expect(search!.width).toBeGreaterThan(180);
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
});
for (const width of [390, 1280]) {
test(`embedded active session keeps steering and stop inside the portal at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 822 });
await fixtures(page, "embedded");
await page.route("**/api/sessions", route => route.request().method() === "POST"
? route.fulfill({ json: { id: "layout-session" } }) : route.fallback());
await page.route("**/api/sessions/layout-session", route => route.fulfill({ json: { id: "layout-session", active: true, status: "open", workspace } }));
await page.goto("/");
// The Omics template bounds #root below its topbar and clips overflow.
await page.addStyleTag({ content: `
body { margin: 0; padding-top: 70px; }
#root { height: calc(100dvh - 110px); overflow: hidden; }
` });
const prompt = page.getByRole("textbox", { name: "New question", exact: true });
await prompt.fill("Review the cohort");
await page.getByRole("button", { name: "Send", exact: true }).click();
const steering = page.getByRole("textbox", { name: "Steering", exact: true });
await expect(steering).toBeVisible();
// Drive the same store projection as streaming, without a real model.
await page.evaluate(async () => {
const path = "/src/store/sessionStore.ts";
const { useSessionStore } = await import(/* @vite-ignore */ path);
useSessionStore.getState().applyEvent({ type: "text_delta", text: "Reviewing the cohort\n".repeat(80) });
});
await steering.fill("Please pause the analysis\n".repeat(15));
const stop = page.getByRole("button", { name: "Stop and save session", exact: true });
await page.locator("#root").evaluate(el => { el.scrollTop = 0; });
const root = await page.locator("#root").boundingBox();
for (const control of [steering, stop]) {
const box = await control.boundingBox();
expect(box!.y).toBeGreaterThanOrEqual(root!.y);
expect(box!.y + box!.height).toBeLessThanOrEqual(root!.y + root!.height);
await expect(control).toBeInViewport();
}
await page.screenshot({ path: testInfo.outputPath("embedded-session.png") });
await stop.click();
await expect(page.getByRole("dialog", { name: "Stop the session?" })).toBeVisible();
});
}
for (const [width, height, mode] of [
[390, 844, "embedded"], [1280, 900, "embedded"], [1440, 1000, "full"],
[844, 390, "embedded"], [320, 640, "embedded"],
] as const) {
test(`session confirmation layout stays within ${mode} view at ${width}x${height}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height });
await fixtures(page, mode);
await page.route("**/api/sessions", route => route.request().method() === "POST"
? route.fulfill({ json: { id: "layout-session" } }) : route.fallback());
await page.route("**/api/sessions/layout-session", route => route.fulfill({ json: { id: "layout-session", active: true, status: "open", workspace } }));
await page.goto("/");
if (mode === "embedded") await page.addStyleTag({ content: `
body { margin: 0; padding-top: 60px; }
#root { margin-left: ${width > 900 ? 160 : 0}px; height: calc(100dvh - 80px); overflow: hidden; }
` });
await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible();
await page.getByRole("textbox", { name: "New question", exact: true }).fill("Review the cohort");
await page.getByRole("button", { name: "Send", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Steering", exact: true })).toBeVisible();
await page.evaluate(async () => {
const path = "/src/store/sessionStore.ts";
const { useSessionStore } = await import(/* @vite-ignore */ path);
useSessionStore.getState().applyEvent({ type: "ui_request", ui_request: {
id: "layout-gate", widget: "artifact-gate", title: "Verifica della domanda e dei criteri di inclusione",
artifact: { kind: "markdown", content: "## Criteri di inclusione\n\n" + "Verificare i pazienti inclusi nella coorte e il periodo di riferimento.\n\n".repeat(100) },
options: [{ id: "approve", label: "Conferma e procedi alla fase successiva" }], reserved: ["back", "other"],
} });
});
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
const bounds = await dialog.boundingBox();
const area = await page.getByTestId("app-shell").boundingBox();
expect(area).not.toBeNull();
expect(bounds!.x).toBeGreaterThanOrEqual(area!.x);
expect(bounds!.y).toBeGreaterThanOrEqual(area!.y);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(area!.x + area!.width);
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(area!.y + area!.height);
expect(bounds!.width).toBeGreaterThan(Math.min(1000, area!.width - 60));
expect(bounds!.height).toBeGreaterThan(area!.height * 0.8);
const confirmations = dialog.getByRole("button", { name: "Conferma e procedi alla fase successiva" });
await expect(confirmations).toHaveCount(2);
for (const button of await confirmations.all()) {
await button.scrollIntoViewIfNeeded();
await expect(button).toBeInViewport();
}
expect(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const body = dialog.locator(".thot-session-dialog__body");
expect(await body.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true);
await body.evaluate(el => { el.scrollTop = el.scrollHeight; });
await page.screenshot({ path: testInfo.outputPath("session-review.png") });
await page.evaluate(async () => {
const path = "/src/store/sessionStore.ts";
const { useSessionStore } = await import(/* @vite-ignore */ path);
useSessionStore.getState().clearPending();
});
await page.getByRole("button", { name: "Stop and save session", exact: true }).click();
const stop = page.getByRole("dialog", { name: "Stop the session?" });
await expect(stop.getByRole("button", { name: "Stop & save", exact: true })).toHaveCount(2);
await expect(stop.getByRole("button", { name: "Cancel", exact: true })).toBeFocused();
const stopBounds = await stop.boundingBox();
expect(stopBounds!.width).toBeGreaterThan(Math.min(600, area!.width - 60));
expect(stopBounds!.y).toBeGreaterThanOrEqual(area!.y);
expect(stopBounds!.y + stopBounds!.height).toBeLessThanOrEqual(area!.y + area!.height);
await page.screenshot({ path: testInfo.outputPath("session-stop.png") });
await stop.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(stop).toHaveCount(0);
});
}
+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",
"@fontsource-variable/manrope": "5.3.0",
@@ -53,6 +54,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",
"@fontsource-variable/manrope": "5.3.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");
});
});
+19 -4
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({
@@ -38,12 +46,19 @@ test("local authentication renders the standalone logout control", async () => {
render(<App />);
expect(await screen.findByRole("button", { name: "New session" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "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 />);
@@ -98,7 +113,7 @@ test("a local login always enters the core activity", async () => {
await userEvent.type(screen.getByLabelText(/^password$/i), "correct-password");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByRole("button", { name: "New session" }))
expect(await screen.findByRole("button", { name: "Session" }))
.toHaveAttribute("aria-current", "page");
expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument();
});
+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>}
</>;
}
+9 -1
View File
@@ -7,6 +7,7 @@ import { LoginPage } from "./LoginPage";
import { clearAuthState, setAuthState } from "./authState";
import * as authApi from "../api/auth";
import { server } from "../test/msw";
import { setLocale } from "../i18n";
const localConfig = { mode: "local", localLogin: true, oidcLogin: false } as const;
const oidcConfig = { mode: "oidc", localLogin: false, oidcLogin: true } as const;
@@ -26,9 +27,16 @@ const authenticated = {
};
beforeEach(() => clearAuthState());
afterEach(() => clearAuthState());
afterEach(() => { clearAuthState(); setLocale("en"); });
describe("LoginPage", () => {
test.each(["en", "it"])("keeps only the main sign-in title in %s", locale => {
setLocale(locale);
render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
expect(screen.getByRole("heading", { name: locale === "it" ? "Accedi a ThothII" : "Sign in to ThothII" })).toBeInTheDocument();
expect(screen.queryByText(locale === "it" ? "Accesso a ThothII" : "ThothII access")).not.toBeInTheDocument();
expect(screen.queryByText(locale === "it" ? "Usa l’account della tua installazione per continuare." : "Use your installation account to continue.")).not.toBeInTheDocument();
});
test("shows an unchecked local Remember me control and clears the password after failure", async () => {
let submittedPassword = "";
server.use(http.post("/api/auth/local/login", async ({ request }) => {
+16 -25
View File
@@ -1,6 +1,7 @@
import { useI18n } from "../i18n";
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { AlertTriangle, ArrowRight, Eye, EyeOff, LockKeyhole } from "lucide-react";
import { AlertTriangle, ArrowRight, Eye, EyeOff } from "lucide-react";
import { ApiError } from "../api/client";
import { beginOidcLogin, loginLocal } from "../api/auth";
import type { AuthenticatedUser, AuthPublicConfig } from "../api/types";
@@ -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,20 @@ 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>
</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="font-heading text-3xl font-semibold tracking-tight">{t("Sign in to ThothII")}</h2>
</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 +100,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 +113,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 +129,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 +143,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 -7
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
@@ -67,28 +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
{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-xs text-muted-foreground">
{error.message}
@@ -0,0 +1,31 @@
.thot-session-dialog[data-slot="dialog-content"] {
--session-dialog-space: 1rem;
--session-dialog-height: min(100dvh, var(--app-area-height, 100dvh));
top: calc(var(--app-area-top, 0px) + var(--session-dialog-height) / 2);
width: min(40rem, calc(var(--app-area-width, 100vw) - var(--session-dialog-space)));
max-width: calc(100vw - var(--session-dialog-space));
min-height: min(18rem, calc(var(--session-dialog-height) - var(--session-dialog-space)));
max-height: calc(var(--session-dialog-height) - var(--session-dialog-space));
overflow: auto;
overscroll-behavior: contain;
overflow-wrap: anywhere;
padding: 1.5rem;
gap: 1.25rem;
}
.thot-session-dialog[data-review] {
width: min(80rem, calc(var(--app-area-width, 100vw) - var(--session-dialog-space)));
height: calc(var(--session-dialog-height) - var(--session-dialog-space));
}
.thot-session-dialog [data-slot="dialog-title"] { padding-right: 1.5rem; line-height: 1.4; }
.thot-session-dialog [data-slot="dialog-footer"] { margin: 0; border-radius: 0; }
.thot-session-dialog button { max-width: 100%; white-space: normal; height: auto; min-height: 2rem; }
.thot-session-dialog__body { min-height: 4rem; overflow: auto; overscroll-behavior: contain; }
@media (min-width: 640px) and (min-height: 600px) {
.thot-session-dialog[data-slot="dialog-content"] { --session-dialog-space: 3rem; }
}
@media (max-width: 639px), (max-height: 599px) {
.thot-session-dialog[data-slot="dialog-content"] { padding: 1rem; gap: 0.75rem; }
}
@@ -0,0 +1,9 @@
import type { ComponentProps } from "react";
import { DialogContent } from "./ui/dialog";
import "./SessionDialogContent.css";
/** Session-only sizing; administrative dialogs retain their own layout. */
export function SessionDialogContent({ className = "", review = false, ...props }:
ComponentProps<typeof DialogContent> & { review?: boolean }) {
return <DialogContent {...props} className={`thot-session-dialog ${className}`} data-review={review || undefined} />;
}
+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
+142
View File
@@ -0,0 +1,142 @@
export const itCore: Record<string, string> = {
"Type your question below. Review each step before creating your datamart.": "Scrivi la domanda qui sotto. Rivedi ogni passaggio prima di creare il datamart.",
"Working context": "Contesto di lavoro",
"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",
"Session": "Sessione",
"Start with Session.": "Inizia con 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",
};
+279
View File
@@ -0,0 +1,279 @@
// 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",
"To Administration": "Vai all’Amministrazione",
"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.",
};
+222 -23
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; }
/*
* Product theme — shares the Omics Portal identity (Manrope, 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 Variable", "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Arial, sans-serif;
--font-heading: var(--font-sans);
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
@@ -69,8 +76,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;
@@ -108,52 +115,54 @@
--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";
}
#root :is(input, textarea)::placeholder {
:where(#root, [data-base-ui-portal]) :is(input, textarea)::placeholder {
color: oklch(var(--muted-foreground));
opacity: 1;
}
#root :is(button, input, select, textarea, summary):focus-visible {
:where(#root, [data-base-ui-portal]) :is(button, input, select, textarea, summary):focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: 2px;
}
@@ -163,38 +172,37 @@
outline: none;
}
#root :where(h1, h2, h3, h4, h5, h6),
[data-slot="dialog-content"] :where(h1, h2, h3, h4, h5, h6) {
:where(#root, [data-base-ui-portal]) :where(h1, h2, h3, h4, h5, h6) {
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);
}
}
@@ -204,6 +212,100 @@
* 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;
}
/* Embedded hosts can reserve space for their own header/footer around #root. */
.thot-host--embedded { max-height: 100%; }
.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 {
/* Match Omics static/css/gsd-theme.css --gsd-red-primary in both themes. */
--thot-header-background: #cb333b;
--thot-header-foreground: oklch(99.85% 0.0006 17.2);
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 var(--thot-header-background);
background: var(--thot-header-background);
color: var(--thot-header-foreground);
}
.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: var(--text-control); }
.thot-full-header__error { flex-basis: 100%; font-size: var(--text-control); color: inherit; }
.thot-full-header__controls button { color: var(--thot-header-foreground); }
.thot-full-header__controls button:is(:hover, [aria-expanded="true"]) {
color: var(--thot-header-foreground);
background: color-mix(in srgb, var(--thot-header-foreground) 14%, transparent);
}
.thot-full-header :is(button, select):focus-visible {
outline: 2px solid var(--thot-header-foreground);
outline-offset: 2px;
}
.thot-full-header .thot-shell-select {
color: var(--thot-header-foreground);
background: transparent;
border-color: color-mix(in srgb, var(--thot-header-foreground) 65%, transparent);
}
.thot-full-header .thot-shell-select option {
color: oklch(var(--foreground));
background: oklch(var(--card));
}
/* Native selects can match :focus-visible after a pointer click as well.
Suppress only that outer ring; keyboard focus retains its visible outline. */
.thot-full-header .thot-shell-select[data-pointer-focus="true"]:focus {
outline: none;
box-shadow: none;
}
.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 var(--text-control)/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. */
@@ -219,6 +321,46 @@
padding: 0 1rem 0.75rem;
}
.thot-session-accordion { display: flex; flex-direction: column; }
.thot-session-accordion__item {
display: flex;
flex-direction: column;
flex: 0 0 auto;
border-top: 1px solid oklch(var(--border));
}
.thot-session-accordion__item[data-open] {
flex: 1 1 0;
min-height: 2.5rem;
max-height: calc(min(18rem, 35dvh) + 2.5rem);
}
.thot-session-accordion__item > h3 { flex: none; }
.thot-session-accordion__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
min-height: 2.5rem;
padding: 0.5rem;
font: 600 var(--text-control)/1.5 var(--font-sans);
text-align: left;
}
.thot-session-accordion__trigger:hover { background: oklch(var(--muted)); }
.thot-session-accordion__panel {
flex: 1 1 auto;
max-height: min(18rem, 35dvh);
min-height: 0;
overflow-y: auto;
overscroll-behavior-y: contain;
scrollbar-gutter: stable;
padding: 0.25rem;
}
.thot-session-accordion__trigger:focus-visible,
.thot-session-accordion__panel:focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: -2px;
}
/* UI labels use the same family as controls; monospace is for technical data. */
.thot-label {
font-family: var(--font-sans);
@@ -249,6 +391,52 @@
.thot-prose h2 { font-size: var(--text-section); }
.thot-prose h3 { font-size: var(--text-body); }
.thot-prose :where(p, ul, ol, blockquote) { max-width: 75ch; }
/* Shared reading contract for Memory and Evidence, independent of UI controls. */
.thot-knowledge-reader,
.thot-knowledge-prose {
font-family: var(--font-sans);
font-size: var(--text-body);
font-weight: 400;
line-height: 1.65;
letter-spacing: normal;
color: oklch(var(--foreground));
width: 100%;
min-width: 0;
max-width: none;
overflow-wrap: anywhere;
}
.thot-knowledge-title {
font: 600 var(--text-page)/1.3 var(--font-sans);
letter-spacing: -0.015em;
margin-top: 0.25rem;
}
.thot-knowledge-heading {
font: 600 var(--text-section)/1.4 var(--font-sans);
letter-spacing: normal;
margin: 0 0 0.5rem;
}
.thot-knowledge-meta {
font: 400 var(--text-control)/1.5 var(--font-sans);
letter-spacing: normal;
}
.thot-knowledge-meta dt { font-weight: 600; margin-bottom: 0.25rem; }
.thot-knowledge-reader code,
.thot-knowledge-reader pre,
.thot-prose.thot-knowledge-prose code {
font-family: var(--font-mono);
font-size: var(--text-control);
line-height: 1.65;
}
.thot-prose.thot-knowledge-prose :where(h1, h2, h3, h4, h5, h6) {
font: 600 var(--text-body)/1.5 var(--font-sans);
letter-spacing: normal;
margin: 1.5rem 0 0.5rem;
}
.thot-prose.thot-knowledge-prose > :first-child { margin-top: 0; }
.thot-knowledge-prose strong { font-weight: 600; }
.thot-knowledge-prose :where(p, ul, ol, blockquote) { max-width: none; }
.thot-prose.thot-knowledge-prose p + p { margin-top: 1.25em; }
.thot-knowledge-prose pre { white-space: pre-wrap; overflow-wrap: anywhere; }
.thot-prose ul, .thot-prose ol { margin: 0.6em 0; padding-left: 1.3em; }
.thot-prose li { margin: 0.25em 0; }
.thot-prose a { color: oklch(var(--primary)); text-decoration: underline; text-underline-offset: 2px; }
@@ -347,8 +535,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: var(--text-control);
--ag-background-color: oklch(var(--card));
@@ -95,7 +95,7 @@ test("Back and Core navigation preserve a Memory draft until the editor cancels
act(() => window.history.back());
await waitFor(() => expect(new URLSearchParams(window.location.search).get("thoth_route")).toBe("administration/memory"));
expect(within(page).getByLabelText("Title")).toHaveValue("Keep this draft");
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
await userEvent.click(screen.getByRole("button", { name: "Session" }));
expect(page).toBeVisible();
expect(confirm).not.toHaveBeenCalled();
confirm.mockReturnValue(true);
@@ -128,6 +128,8 @@ test("option A has one collapsible global context and preserves the Core draft a
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
expect(screen.getAllByRole("combobox", { name: "Workspace" })).toHaveLength(1);
expect(screen.getAllByRole("combobox", { name: "Model" })).toHaveLength(1);
expect(screen.getByRole("button", { name: "Done" }).closest(".thot-context-fields"))
.toBe(screen.getByRole("combobox", { name: "Model" }).closest(".thot-context-fields"));
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta");
expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" });
await userEvent.click(screen.getByRole("button", { name: "Done" }));
@@ -136,7 +138,7 @@ test("option A has one collapsible global context and preserves the Core draft a
await userEvent.click(screen.getByRole("button", { name: "Memory" }));
expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible();
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
await userEvent.click(screen.getByRole("button", { name: "Session" }));
expect(screen.getByRole("textbox", { name: /new question/i })).toBe(composer);
expect(composer).toHaveValue("Keep my original Core question");
});
@@ -147,7 +149,7 @@ test("invalid remembered choices do not silently fall back and gate both Core an
window.history.replaceState(null, "", "/?thoth_route=administration/memory");
renderShell();
await screen.findByText(/Unavailable saved choices are not replaced/);
expect(screen.getByRole("button", { name: "New session" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Session" })).toBeDisabled();
expect(screen.queryByRole("main", { name: "Memory management" })).not.toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "alpha");
@@ -191,7 +193,7 @@ test("dirty Memory blocks workspace and model changes, including after a failed
await userEvent.click(within(page).getByRole("button", { name: "Save card" }));
await waitFor(() => expect(saveAttempts).toBe(1));
expect(await within(page).findByRole("alert")).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
await userEvent.click(screen.getByRole("button", { name: "Session" }));
expect(page).toBeVisible();
expect(within(page).getByLabelText("Title")).toHaveValue("Do not lose this");
});
@@ -220,6 +222,7 @@ test("Resume binds the pinned workspace immediately and leaves the global model
);
renderShell();
await screen.findByRole("textbox", { name: /new question/i });
await userEvent.click(screen.getByRole("button", { name: "Active sessions" }));
await userEvent.click(await screen.findByTestId("session-item-in-beta"));
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" });
@@ -243,12 +246,12 @@ test("a successful Memory save releases the navigation guard and the context loc
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
await userEvent.click(within(page).getByRole("button", { name: "Save card" }));
expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
await userEvent.click(screen.getByRole("button", { name: "Session" }));
expect(page).toBeVisible();
await act(async () => release());
await within(page).findByText("Saved and indexed.");
expect(screen.getByRole("combobox", { name: "Model" })).toBeEnabled();
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
await userEvent.click(screen.getByRole("button", { name: "Session" }));
expect(await screen.findByRole("textbox", { name: /new question/i })).toBeVisible();
});
+18 -6
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"],
@@ -109,7 +118,7 @@ describe("authenticated shell permissions", () => {
test("hides administrative navigation from a session user", async () => {
renderShell({ subject: "user-1", isAdmin: false, roles: ["user"], permissions: ["session.use"] });
expect(await screen.findByRole("button", { name: "New session" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Session" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Database" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace" })).not.toBeInTheDocument();
@@ -150,7 +159,9 @@ describe("authenticated shell permissions", () => {
expect(panel.children[4]).toBe(workspace);
expect(panel.children[5]).toBe(pi);
expect(within(panel).queryByRole("region", { name: "Workspace preprocessing" })).not.toBeInTheDocument();
expect(within(panel).getByText(/Workspace readiness/)).toBeInTheDocument();
expect(within(panel).queryByText(/Workspace readiness/)).not.toBeInTheDocument();
expect(within(workspace).getByRole("img", { name: /Workspace readiness/ })).toBeInTheDocument();
expect(workspace).toHaveAccessibleDescription(/Workspace readiness/);
expect(screen.getByRole("tab", { name: "All sessions" })).toBeInTheDocument();
await user.keyboard(" ");
@@ -196,7 +207,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 +239,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 +253,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 +269,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();
@@ -309,6 +320,7 @@ describe("authenticated shell permissions", () => {
}
renderUserShell();
await userEvent.click(await screen.findByRole("button", { name: "Active sessions" }));
await userEvent.click(await screen.findByTestId("session-item-panel-a"));
expect(await screen.findByText("A-private-document")).toBeInTheDocument();
act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "A-private-transcript" }));
@@ -105,7 +105,7 @@ test("starts on the core activity after a hard refresh even if metadata manageme
await expandAdministration();
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
expect(within(sessionNavigation).getByRole("button", { name: "New session" }))
expect(within(sessionNavigation).getByRole("button", { name: "Session" }))
.toHaveAttribute("aria-current", "page");
expect(within(sessionNavigation).getByRole("button", { name: "Database" }))
.not.toHaveAttribute("aria-current");
@@ -118,7 +118,7 @@ test("replaces the core conversation while keeping the session navigation", asyn
const conversationColumn = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
const newSession = within(sessionNavigation).getByRole("button", { name: "Session" });
const databaseManagement = within(sessionNavigation).getByRole("button", { name: "Database" });
expect(newSession).toHaveAttribute("aria-current", "page");
@@ -146,6 +146,7 @@ test("replaces the core conversation while keeping the session navigation", asyn
});
test("keeps a live core session connected while returning from database management", async () => {
let resumes = 0;
server.use(
http.get("/api/sessions", () => HttpResponse.json([{
id: "s1",
@@ -160,10 +161,10 @@ test("keeps a live core session connected while returning from database manageme
archived: false,
active: true,
}])),
http.post("/api/sessions/:id/resume", ({ params }) => HttpResponse.json({
http.post("/api/sessions/:id/resume", ({ params }) => { resumes++; return HttpResponse.json({
id: params.id,
alreadyActive: true,
})),
}); }),
http.get("/api/sessions/:id", ({ params }) => HttpResponse.json({
id: params.id,
status: "open",
@@ -173,6 +174,7 @@ test("keeps a live core session connected while returning from database manageme
renderShell();
await expandAdministration();
await userEvent.click(screen.getByRole("button", { name: "Active sessions" }));
const session = await screen.findByTestId("session-item-s1");
await userEvent.click(session);
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
@@ -185,13 +187,19 @@ test("keeps a live core session connected while returning from database manageme
expect(FakeEventSource.instances).toHaveLength(1);
expect(session).toBeVisible();
await userEvent.click(session);
expect(screen.queryByRole("button", { name: "Return to session" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "New session" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Session" }));
await waitFor(() => {
expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument();
});
expect(source.closed).toBe(false);
expect(FakeEventSource.instances).toHaveLength(1);
expect(resumes).toBe(1);
await userEvent.click(screen.getByRole("button", { name: "Session" }));
expect(source.closed).toBe(false);
expect(resumes).toBe(1);
});
test("hides the complete administrative navigation from a regular user", () => {
@@ -208,7 +216,7 @@ test("hides the complete administrative navigation from a regular user", () => {
renderShell();
expect(screen.getByRole("button", { name: "New session" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Session" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Database" })).not.toBeInTheDocument();
@@ -256,7 +264,7 @@ test("requires saving or cancelling a dirty database form before leaving", async
await userEvent.clear(schema);
await userEvent.type(schema, "reporting");
const newSession = screen.getByRole("button", { name: "New session" });
const newSession = screen.getByRole("button", { name: "Session" });
await userEvent.click(newSession);
expect(confirm).not.toHaveBeenCalled();
@@ -47,9 +47,11 @@ beforeEach(() => {
);
});
test("New session starts prewarm without delaying composer focus", async () => {
test("Session prepares a new question without creating a session before submission", async () => {
let prewarmStarted = false;
const create = vi.fn(() => HttpResponse.json({ id: "unexpected" }));
server.use(
http.post("/api/sessions", create),
http.post("/api/runtime/prewarm", async () => {
prewarmStarted = true;
await delay(100);
@@ -58,16 +60,18 @@ test("New session starts prewarm without delaying composer focus", async () => {
);
renderShell();
await userEvent.click(screen.getByRole("button", { name: /^new session$/i }));
await userEvent.click(screen.getByRole("button", { name: /^Session$/i }));
const composer = await screen.findByRole("textbox", { name: /new question/i });
await waitFor(() => expect(prewarmStarted).toBe(true));
await waitFor(() => expect(composer).toHaveFocus());
expect(composer).toHaveAttribute("data-awaiting-input", "true");
expect(composer).toHaveClass("thot-awaiting-input");
expect(create).not.toHaveBeenCalled();
expect(screen.getAllByRole("button", { name: "Session" })).toHaveLength(1);
});
test("a known preprocessing requirement disables New session", async () => {
test("a known preprocessing requirement disables Session when no session is open", async () => {
server.use(
http.get("/api/workspaces/default/preprocessing", () => HttpResponse.json({
schemaVersion: 1,
@@ -81,7 +85,7 @@ test("a known preprocessing requirement disables New session", async () => {
);
renderShell();
const newSession = screen.getByRole("button", { name: "New session" });
const newSession = screen.getByRole("button", { name: "Session" });
await waitFor(() => expect(newSession).toBeDisabled());
expect(newSession).toHaveAttribute("data-navigation-state", "unavailable");
await waitFor(() => expect(newSession).toHaveAttribute("title", "Catalog revision 18 is not indexed."));
@@ -110,6 +114,10 @@ test("records the prompt without central duplication, then opens the live log",
phase: "F1",
text: "How many patients?",
});
// Clicking the unified navigation while creation is pending must not discard
// its operation token or the provisional transcript.
await userEvent.click(screen.getByRole("button", { name: "Session" }));
expect(useSessionStore.getState().activityLog[0]?.text).toBe("How many patients?");
releaseCreate();
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
act(() => FakeEventSource.instances[0].emit({ type: "text_delta", text: "Inspecting cohort" }));
@@ -211,7 +219,7 @@ test("opens Workspace management from the right sidebar without interrupting the
const workArea = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
const workspaceManagement = within(sessionNavigation).getByRole("button", { name: "Workspace" });
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
const newSession = within(sessionNavigation).getByRole("button", { name: "Session" });
expect(workArea).toContainElement(dialog);
expect(sessionNavigation).not.toContainElement(dialog);
expect(screen.getByTestId("app-shell")).toHaveAttribute("data-activity-layout", "closed");
@@ -222,7 +230,7 @@ test("opens Workspace management from the right sidebar without interrupting the
expect(newSession).toHaveAttribute("data-navigation-state", "available");
expect(newSession).not.toHaveAttribute("aria-current");
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
await userEvent.click(screen.getByRole("button", { name: "Session" }));
await waitFor(() => expect(screen.queryByRole("main", { name: "Workspace management" })).not.toBeInTheDocument());
expect(newSession).toHaveAttribute("aria-current", "page");
expect(newSession).toHaveAttribute("data-navigation-state", "current");
@@ -240,7 +248,7 @@ test("does not block the shell when the DWH is unavailable at startup", async ()
);
renderShell();
expect(await screen.findByRole("button", { name: "New session" })).toBeVisible();
expect(await screen.findByRole("button", { name: "Session" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Connection unavailable" })).not.toBeInTheDocument();
expect(healthChecks).toBe(0);
@@ -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"]);
});
+104 -14
View File
@@ -20,10 +20,13 @@ const adminUser: AuthenticatedUser = {
permissions: ["session.use", "session.read_all", "workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage"] as const, isAdmin: true,
};
function wrap(user: AuthenticatedUser = regularUser) {
function wrap(user: AuthenticatedUser = regularUser, openActive = true) {
if (user !== regularUser) setAuthState(user);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><AppShell canLogout={false} /></QueryClientProvider>);
const view = render(<QueryClientProvider client={client}><AppShell canLogout={false} /></QueryClientProvider>);
// Session-operation tests explicitly open the list before using its rows.
if (openActive) fireEvent.click(screen.getByRole("button", { name: "Active sessions" }));
return view;
}
const LIST = [
@@ -36,13 +39,21 @@ const resumeResult = (id: string, alreadyActive = false) =>
class ControlledResizeObserver {
static instances: ControlledResizeObserver[] = [];
private targets = new Set<Element>();
constructor(private readonly callback: ResizeObserverCallback) {
ControlledResizeObserver.instances.push(this);
}
observe = vi.fn();
disconnect = vi.fn();
observe = vi.fn((target: Element) => { this.targets.add(target); });
disconnect = vi.fn(() => { this.targets.clear(); });
trigger() {
this.callback([], this as unknown as ResizeObserver);
const entries: ResizeObserverEntry[] = [...this.targets].map(target => ({
target,
contentRect: new DOMRect(0, 0, target.clientWidth, target.clientHeight),
borderBoxSize: [],
contentBoxSize: [],
devicePixelContentBoxSize: [],
}));
if (entries.length) this.callback(entries, this as unknown as ResizeObserver);
}
}
@@ -128,7 +139,7 @@ test("Pi management preserves the open session summary and the model activity ti
const workArea = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
const piManagement = within(sessionNavigation).getByRole("button", { name: "Pi configuration" });
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
const newSession = within(sessionNavigation).getByRole("button", { name: "Session" });
expect(workArea).toContainElement(dialog);
expect(sessionNavigation).not.toContainElement(dialog);
expect(piManagement).toHaveAttribute("aria-current", "page");
@@ -139,7 +150,9 @@ test("Pi management preserves the open session summary and the model activity ti
const preservedSummary = document.querySelector('[aria-label="Session summary"]');
expect(preservedSummary).toHaveAttribute("aria-hidden", "true");
expect(preservedSummary).toHaveTextContent("Attiva uno");
await user.click(screen.getByRole("button", { name: "Return to session" }));
// A cold document panel is not a running session. Reopen it from its row;
// the unified Session action would prepare a new question instead.
await user.click(screen.getByTestId("session-item-s1"));
await waitFor(() => {
expect(screen.getByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
});
@@ -152,7 +165,7 @@ test("Pi management preserves the open session summary and the model activity ti
await screen.findByRole("button", { name: "Show model activity" });
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Preserved activity" }));
await user.click(screen.getByRole("button", { name: "Pi configuration" }));
await user.click(await screen.findByRole("button", { name: "Return to session" }));
await user.click(await screen.findByRole("button", { name: "Session" }));
await waitFor(() => expect(screen.queryByRole("heading", { name: "Pi management" })).not.toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "Show model activity" }));
expect(await screen.findByRole("heading", { name: "Model activity" })).toBeVisible();
@@ -182,6 +195,10 @@ test("administrators can explicitly switch to all sessions and see owners", asyn
expect(mySessions).toHaveAttribute("tabindex", "0");
expect(mySessions).toHaveAttribute("data-tab-state", "active");
expect(mySessions).toHaveClass("bg-[oklch(var(--nav-active))]");
expect(mySessions).toHaveClass("border", "px-[11px]", "py-[3px]");
expect(allSessions).toHaveClass("border", "px-[11px]", "py-[3px]");
expect(mySessions).not.toHaveClass("border-b-0", "border-b-2", "-mb-px");
expect(tablist).not.toHaveClass("border-b");
expect(allSessions).toHaveAttribute("aria-selected", "false");
expect(allSessions).toHaveAttribute("aria-controls", "session-scope-panel");
expect(allSessions).toHaveAttribute("tabindex", "-1");
@@ -256,7 +273,7 @@ test("administrator confirms before deleting a same-named user's session", async
await userEvent.click(screen.getByRole("button", { name: "Delete 1 selected sessions" }));
expect(deletes).toBe(0);
expect(await screen.findByRole("heading", { name: "Delete permanently" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
await userEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]);
await waitFor(() => expect(deletes).toBe(1));
});
@@ -393,7 +410,7 @@ test("New session closes an open session detail panel", async () => {
wrap();
await userEvent.click(await screen.findByText("Attiva uno")); // cold session → panel opens
await screen.findByText("Domanda originale");
await userEvent.click(screen.getByRole("button", { name: /^new session$/i }));
await userEvent.click(screen.getByRole("button", { name: /^Session$/i }));
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument());
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
});
@@ -405,8 +422,80 @@ test("Archive accordion expands to reveal archived sessions", async () => {
expect(screen.queryByText("Archiviata due")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /archive/i }));
expect(await screen.findByText("Archiviata due")).toBeInTheDocument();
// Active sessions remain visible (it is a separate accordion, not a swap)
expect(screen.getByText("Attiva uno")).toBeInTheDocument();
expect(screen.queryByText("Attiva uno")).not.toBeInTheDocument();
});
test("session accordion permits only one open panel and supports keyboard toggles", async () => {
wrap(regularUser, false);
await screen.findByRole("button", { name: "Archive (1)" });
const active = screen.getByRole("button", { name: "Active sessions" });
const archive = screen.getByRole("button", { name: "Archive (1)" });
expect(active).toHaveAttribute("aria-expanded", "false");
expect(archive).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("Sessions", { exact: true })).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: "Select all" })).not.toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Active sessions" })).not.toBeInTheDocument();
active.focus();
await userEvent.keyboard("{Enter}");
expect(screen.getByRole("region", { name: "Active sessions" })).toHaveClass("thot-session-accordion__panel");
archive.focus();
await userEvent.keyboard("{Enter}");
expect(archive).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("region", { name: "Archive (1)" })).toHaveAttribute("tabindex", "0");
expect(active).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("Attiva uno")).not.toBeInTheDocument();
active.focus();
await userEvent.keyboard(" ");
expect(active).toHaveAttribute("aria-expanded", "true");
expect(archive).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("Archiviata due")).not.toBeInTheDocument();
await userEvent.keyboard(" ");
expect(active).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("Attiva uno")).not.toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Archive (1)" })).not.toBeInTheDocument();
});
test("each list selects only its own sessions and preserves selection in the other list", async () => {
server.use(http.get("/api/sessions", () => HttpResponse.json([
...LIST, { ...LIST[0], id: "s3", question: "Attiva tre" },
])));
wrap();
await screen.findByText("Attiva tre");
const active = within(screen.getByRole("region", { name: "Active sessions" }));
const activeAll = active.getByRole("checkbox", { name: "Select all" });
await userEvent.click(active.getByRole("checkbox", { name: "Select Attiva uno" }));
expect(activeAll).toBePartiallyChecked();
await userEvent.click(activeAll);
expect(activeAll).toBeChecked();
expect(active.getByRole("checkbox", { name: "Select Attiva tre" })).toBeChecked();
await userEvent.click(screen.getByRole("button", { name: "Archive (1)" }));
const archiveAll = within(screen.getByRole("region", { name: "Archive (1)" })).getByRole("checkbox", { name: "Select all" });
expect(archiveAll).not.toBeChecked();
await userEvent.click(archiveAll);
await userEvent.click(screen.getByRole("button", { name: "Active sessions" }));
const reopenedActive = within(screen.getByRole("region", { name: "Active sessions" }));
expect(reopenedActive.getByRole("checkbox", { name: "Select all" })).toBeChecked();
await userEvent.click(reopenedActive.getByRole("checkbox", { name: "Select all" }));
expect(reopenedActive.getByRole("checkbox", { name: "Select all" })).not.toBeChecked();
expect(reopenedActive.queryByRole("button", { name: /Delete.*selected sessions/ })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Archive (1)" }));
const archive = within(screen.getByRole("region", { name: "Archive (1)" }));
expect(archive.getByRole("checkbox", { name: "Select Archiviata due" })).toBeChecked();
expect(archive.getByRole("button", { name: "Delete 1 selected sessions" })).toBeInTheDocument();
});
test.each([false, true])("empty accordion list has no selection controls (archived=%s)", async (archived) => {
server.use(http.get("/api/sessions", () => HttpResponse.json([
{ ...LIST[0], archived, group: null },
])));
wrap(regularUser, false);
await screen.findByRole("button", { name: `Archive (${archived ? 1 : 0})` });
await userEvent.click(screen.getByRole("button", { name: archived ? "Active sessions" : "Archive (0)" }));
const emptyPanel = screen.getByRole("region", { name: archived ? "Active sessions" : "Archive (0)" });
expect(within(emptyPanel).queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: "Select all" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: archived ? "Archive (1)" : "Active sessions" }));
expect(screen.getAllByRole("checkbox", { name: "Select all" })).toHaveLength(1);
});
test("Resume from the panel activates the session and closes the panel", async () => {
@@ -812,7 +901,7 @@ test("starting a new question invalidates a pending Resume intent", async () =>
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await resumeStarted.promise;
await userEvent.click(screen.getByRole("button", { name: /^new session$/i }));
await userEvent.click(screen.getByRole("button", { name: /^Session$/i }));
expect(await screen.findByText(/type your question/i)).toBeInTheDocument();
resumeGate.resolve();
@@ -976,6 +1065,7 @@ test("deleting another session does not invalidate a pending Resume", async () =
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
expect(FakeEventSource.instances[0].url).toContain("/sessions/s1/events");
await userEvent.click(screen.getByRole("button", { name: "Active sessions" }));
expect(screen.getByTestId("session-item-s1")).toHaveAttribute("data-active", "true");
});
@@ -1163,7 +1253,7 @@ test("renaming a group reassigns its members via setSessionGroup", async () => {
const input = await screen.findByLabelText(/name/i, { selector: "input" });
await userEvent.clear(input);
await userEvent.type(input, "Cardiologia");
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await userEvent.click(screen.getAllByRole("button", { name: /save/i })[0]);
await waitFor(() => expect(groupSets).toEqual([{ id: "s1", group: "Cardiologia" }]));
});
@@ -57,10 +57,11 @@ test("a held stop for s1 cannot reset the newer active s2 session", async () =>
}),
);
renderShell();
await userEvent.click(await screen.findByRole("button", { name: "Active sessions" }));
await userEvent.click(await screen.findByTestId("session-item-s1"));
await waitFor(() => expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s1/events"));
await userEvent.click(screen.getByRole("button", { name: /stop and save session/i }));
await userEvent.click(await screen.findByRole("button", { name: "Stop & save" }));
await userEvent.click((await screen.findAllByRole("button", { name: "Stop & save" }))[0]);
await closeStarted.promise;
await userEvent.click(screen.getByTestId("session-item-s2"));
@@ -97,6 +98,7 @@ test("a held new-session completion cannot replace the newer active s2 target",
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await createStarted.promise;
await userEvent.click(screen.getByRole("button", { name: "Active sessions" }));
await userEvent.click(screen.getByTestId("session-item-s2"));
await waitFor(() => expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s2/events"));
act(() => useSessionStore.setState({ currentPhase: "F2" }));
+179 -155
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;
@@ -56,7 +61,7 @@ interface AppShellProps {
const SESSION_SCOPES: readonly SessionScope[] = ["mine", "all"];
function sessionScopeTabClass(selected: boolean): string {
const base = "relative -mb-px flex h-8 w-full cursor-pointer items-center justify-center rounded-t-md border border-b-2 px-2 text-xs font-semibold tracking-[0.005em] outline-none focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-[oklch(var(--nav-active-border)/0.28)]";
const base = "relative flex min-h-[38px] w-full cursor-pointer items-center justify-center rounded-md border px-[11px] py-[3px] text-xs font-semibold tracking-[0.005em] outline-none focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-[oklch(var(--nav-active-border)/0.28)]";
return selected
? `${base} border-[oklch(var(--nav-active-border))] bg-[oklch(var(--nav-active))] text-[oklch(var(--nav-active-foreground))] hover:bg-[oklch(var(--nav-active-hover))]`
: `${base} border-border bg-card text-muted-foreground hover:border-muted-foreground/45 hover:bg-muted/55 hover:text-foreground`;
@@ -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;
@@ -110,8 +117,7 @@ export function AppShell({ canLogout }: AppShellProps) {
resizing: sessionResizing,
separatorProps: sessionSeparatorProps,
} = useSessionPanelResize(containerRef, panelSession !== null);
// Publish the app area's horizontal geometry so viewport-fixed dialogs center on
// the application area rather than the whole browser window.
// Publish the visible app bounds for dialogs, including embedded portal layouts.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
@@ -120,16 +126,23 @@ export function AppShell({ canLogout }: AppShellProps) {
const r = el.getBoundingClientRect();
doc.style.setProperty("--app-area-left", `${r.left}px`);
doc.style.setProperty("--app-area-width", `${r.width}px`);
const top = Math.max(0, r.top);
doc.style.setProperty("--app-area-top", `${top}px`);
doc.style.setProperty("--app-area-height", `${Math.max(0, Math.min(window.innerHeight, r.bottom) - top)}px`);
};
publish();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(publish);
observer?.observe(el);
window.addEventListener("resize", publish);
window.addEventListener("scroll", publish, true);
return () => {
observer?.disconnect();
window.removeEventListener("resize", publish);
window.removeEventListener("scroll", publish, true);
doc.style.removeProperty("--app-area-left");
doc.style.removeProperty("--app-area-width");
doc.style.removeProperty("--app-area-top");
doc.style.removeProperty("--app-area-height");
};
}, [containerRef]);
const panelWidthsStyle = {
@@ -186,7 +199,11 @@ export function AppShell({ canLogout }: AppShellProps) {
&& preprocessingQuery.data
&& preprocessingQuery.data.state !== "ready",
);
const { data: sessions = [] } = useQuery<SessionSummary[]>({
const workspaceReady = Boolean(selectedWorkspaceId && !preprocessingQuery.isError && preprocessingQuery.data?.state === "ready");
const workspaceReadinessLabel = t("Workspace readiness: {state}", {
state: t(preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking"),
});
const { data: sessions = [], isSuccess: sessionsLoaded } = useQuery<SessionSummary[]>({
queryKey: ["sessions", sessionScope],
queryFn: async () => {
const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
@@ -198,6 +215,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);
@@ -230,7 +262,7 @@ export function AppShell({ canLogout }: AppShellProps) {
const contextLocked = interactionBusy || creatingSession || administrationBusy
|| preprocessingQuery.data?.state === "running";
const [adminNavigationValue, setAdminNavigationValue] = useState<string[]>([]);
const [activeOpen, setActiveOpen] = useState(true);
const [activeOpen, setActiveOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
const [deleteTargets, setDeleteTargets] = useState<SessionSummary[]>([]);
@@ -264,6 +296,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;
@@ -298,8 +332,39 @@ export function AppShell({ canLogout }: AppShellProps) {
});
}
function toggleAllSessions(selected: boolean) {
setSelectedSessionIds(selected ? new Set(sessions.map((session) => session.id)) : new Set());
function sessionSelectionControls(list: SessionSummary[]) {
if (list.length === 0) return null;
const selected = list.filter((session) => selectedSessionIds.has(session.id));
return (
<div className="flex items-center justify-between gap-2 px-2 pb-2">
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground">
<Checkbox
checked={selected.length === list.length}
indeterminate={selected.length > 0 && selected.length < list.length}
onCheckedChange={(checked) => setSelectedSessionIds((current) => {
const next = new Set(current);
for (const session of list) {
if (checked) next.add(session.id);
else next.delete(session.id);
}
return next;
})}
/>
<span>{t("Select all")}</span>
</label>
{selected.length > 0 && (
<Button
variant="destructive"
size="xs"
aria-label={t("Delete {count} selected sessions", { count: selected.length })}
onClick={() => requestDelete(selected)}
>
<Trash2 />
{t("Delete ({count})", { count: selected.length })}
</Button>
)}
</div>
);
}
function handleSessionScopeTabKeyDown(
@@ -323,7 +388,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 +399,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 +512,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 +524,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 +537,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 +554,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 +568,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 +582,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 +612,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 +633,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,15 +669,18 @@ 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;
}, [sessionToasts]);
const sessionViewOpen = Boolean(activeSessionId) || creatingSession;
const canReturnToCurrentSession = creatingSession || (Boolean(activeSessionId) && !finalized && !activeSession?.archived);
const sessionActionUnavailable = !canReturnToCurrentSession && (preprocessingBlocksNewSession || !workingContext.ready || administrationBusy);
const working = sessionViewOpen && !pendingWidget && agentActive;
// The workflow bar runs only while the harness works, not while a finalized
// session sits idle or a gate awaits the reviewer (pendingWidget).
@@ -644,7 +720,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 +767,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 +819,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 +837,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 +874,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 +904,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 +926,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-base font-semibold leading-snug text-foreground break-words">
{activeSession.question}
</h2>
@@ -856,13 +936,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,48 +992,37 @@ 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 min-h-0 w-64 shrink-0 flex-col bg-sidebar">
<div className="thot-session-navigation__header relative text-center">
<h1 className="thot-wordmark--sidebar font-heading text-[2rem] 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>}
<Button
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
size="sm"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "core" ? "page" : undefined}
data-navigation-state={preprocessingBlocksNewSession || !workingContext.ready || administrationBusy
data-navigation-state={sessionActionUnavailable
? "unavailable"
: currentNavigation === "core" ? "current" : "available"}
disabled={preprocessingBlocksNewSession || !workingContext.ready || administrationBusy}
title={preprocessingBlocksNewSession ? preprocessingQuery.data?.detail : undefined}
onClick={startNewSession}
>
New session
</Button>
disabled={sessionActionUnavailable}
title={sessionActionUnavailable && preprocessingBlocksNewSession ? preprocessingQuery.data?.detail : undefined}
onClick={() => {
if (canReturnToCurrentSession) navigate({ surface: "core" });
else startNewSession();
}}
>{t("Session")}</Button>
{isAdmin && (
<Accordion.Root
value={adminNavigationValue}
@@ -976,7 +1040,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,47 +1056,47 @@ 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"}
size="sm"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "workspace" ? "page" : undefined}
aria-label={t("Workspace")}
aria-describedby="workspace-readiness-indicator"
title={workspaceReadinessLabel}
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
navigate({ surface: "workspace-management" }, true);
}}
>
Workspace
</Button>
>{t("Workspace")}<span id="workspace-readiness-indicator" role="img" aria-label={workspaceReadinessLabel}
data-ready={workspaceReady} className={`ml-1 size-2 shrink-0 rounded-full ${workspaceReady ? "bg-[oklch(var(--success))]" : "bg-destructive"}`} /></Button>
<Button
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
size="sm"
@@ -1040,17 +1104,12 @@ 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"}
</Button>
>{t("Pi configuration")}</Button>
</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
@@ -1061,9 +1120,9 @@ 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"
className="grid grid-cols-2 gap-1"
>
<button
ref={(node) => { sessionScopeTabRefs.current.mine = node; }}
@@ -1077,9 +1136,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 +1149,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,54 +1161,24 @@ 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-muted-foreground">
Administrator view: all sessions
<p className="sr-only">
{t("Administrator view: all sessions")}
</p>
)}
{/* L1 — rail title */}
<div className="px-4 pb-1.5 pt-1">
<span className="text-sm font-semibold text-foreground">
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"
disabled={sessions.length === 0}
onCheckedChange={(selected) => toggleAllSessions(selected === true)}
/>
<span>Select all</span>
</label>
{selectedSessions.length > 0 && (
<Button
variant="destructive"
size="xs"
aria-label={`Delete ${selectedSessions.length} selected sessions`}
onClick={() => {
requestDelete(selectedSessions);
}}
>
<Trash2 />
Delete ({selectedSessions.length})
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto px-2 pb-4">
{/* L2 — section toggle */}
<button
type="button"
onClick={() => setActiveOpen((v) => !v)}
aria-expanded={activeOpen}
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>
</button>
{activeOpen && (
<Accordion.Root multiple={false}
value={[...(activeOpen ? ["active"] : []), ...(archiveOpen ? ["archive"] : [])]}
onValueChange={value => { setActiveOpen(value.includes("active")); setArchiveOpen(value.includes("archive")); }}
className="thot-session-accordion min-h-0 flex-1 overflow-y-auto px-2 pb-4">
<Accordion.Item value="active" className="thot-session-accordion__item">
<Accordion.Header className="m-0">
<Accordion.Trigger className="thot-session-accordion__trigger">
<span>{t("Active sessions")}</span>
<ChevronDown aria-hidden="true" className={`size-4 shrink-0 ${activeOpen ? "rotate-180" : ""}`} />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel className="thot-session-accordion__panel" tabIndex={0}>
{sessionSelectionControls(activeList)}
<div className="flex flex-col gap-3 pb-2">
{groups.map((g) => (
<div key={g}>
@@ -1170,7 +1195,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"
>
@@ -1191,8 +1216,7 @@ export function AppShell({ canLogout }: AppShellProps) {
</div>
))}
{/* Ungrouped sessions list directly after the groups — no "No group" label.
When there are no groups at all, still render it so its empty state can
teach first-time users. */}
When there are no groups at all, still render its empty state. */}
{(ungroupedActive.length > 0 || groups.length === 0) && (
<NavSessions
sessions={ungroupedActive}
@@ -1205,19 +1229,17 @@ export function AppShell({ canLogout }: AppShellProps) {
/>
)}
</div>
)}
{/* L2 — section toggle */}
<button
type="button"
onClick={() => setArchiveOpen((v) => !v)}
aria-expanded={archiveOpen}
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>
</button>
{archiveOpen && (
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="archive" className="thot-session-accordion__item">
<Accordion.Header className="m-0">
<Accordion.Trigger className="thot-session-accordion__trigger">
<span className="tabular-nums">{t("Archive ({count})", { count: archivedList.length })}</span>
<ChevronDown aria-hidden="true" className={`size-4 shrink-0 ${archiveOpen ? "rotate-180" : ""}`} />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel className="thot-session-accordion__panel" tabIndex={0}>
{sessionSelectionControls(archivedList)}
<NavSessions
sessions={archivedList}
activeSessionId={activeSessionId}
@@ -1227,13 +1249,14 @@ export function AppShell({ canLogout }: AppShellProps) {
onSelectionChange={setSessionSelected}
showOwner={showingAllSessions}
/>
)}
</div>
</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
</div>
</aside>
</ArchiveNavigation>
)}
<Toaster />
<Toaster theme={shell.theme} />
<StopConfirmDialog
open={stopConfirm}
@@ -1261,7 +1284,7 @@ export function AppShell({ canLogout }: AppShellProps) {
setRenameGroupTarget(null);
}
}}
title="Rename group"
title={t("Rename group")}
/>
)}
{deleteTargets.length > 0 && (
@@ -1284,13 +1307,14 @@ 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="thot-wordmark--core 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-base leading-relaxed text-muted-foreground">
Type your question below. Review each step before creating your datamart.
{t("Type your question below. Review each step before creating your datamart.")}
</p>
</div>
);
+7 -5
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,12 +30,12 @@ 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>
<div className="min-h-0 overflow-y-auto [&>aside]:w-full">{children}</div>
<DialogContent className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col">
<DialogTitle>{translate("Navigation")}</DialogTitle>
<DialogDescription className="sr-only">{translate("Choose an administration page or a session.")}</DialogDescription>
<div className="min-h-0 flex-1 overflow-y-auto [&>aside]:h-full [&>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" : "Catalog summary"}
title={metricsDatabaseId ? t("Database summary") : t("Catalog 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>
) : (
+16 -8
View File
@@ -1,4 +1,7 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
import { SessionDialogContent } from "../components/SessionDialogContent";
import { useId } from "react";
import { useI18n } from "../i18n";
import { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
import { Button } from "../components/ui/button";
interface Props {
@@ -11,22 +14,27 @@ interface Props {
}
export function DeleteConfirmDialog({ open, labels, label, onOpenChange, onConfirm }: Props) {
const { t: translate } = useI18n();
const cancelId = useId();
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] ?? "" });
const confirmAction = (<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>{translate("Delete")}</Button>);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>Delete permanently</DialogTitle></DialogHeader>
<SessionDialogContent initialFocus={() => document.getElementById(cancelId)}>
<DialogHeader><DialogTitle>{translate("Delete permanently")}</DialogTitle></DialogHeader>
<div>{confirmAction}</div>
<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 id={cancelId} type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>{translate("Cancel")}</Button>
{confirmAction}
</DialogFooter>
</DialogContent>
</SessionDialogContent>
</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";
@@ -21,7 +22,40 @@ beforeEach(() => server.use(
http.get("/api/workspaces/sales/evidence", () => HttpResponse.json(page)),
http.get("/api/workspaces/sales/evidence/evidence:order-key", () => HttpResponse.json(page)),
));
test("copy uses an accessible icon and copies the unmodified full path", async () => {
const user = userEvent.setup();
const copy = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue();
renderPage(); await choose();
await user.click(await screen.findByRole("button", { name: "Order key" }));
const detail = screen.getByRole("region", { name: "Evidence detail" });
const button = within(detail).getByRole("button", { name: "Copy Evidence file path" });
expect(button).toHaveTextContent("");
expect(button.querySelector("svg")).not.toBeNull();
await user.click(button);
expect(copy).toHaveBeenCalledWith("/srv/workspaces/repo/sales/evidence/curated/domain/order-key.md");
expect(within(detail).getByRole("status")).toHaveTextContent("Copied");
copy.mockRejectedValueOnce(new Error("Clipboard unavailable"));
await user.click(button);
expect(within(detail).getByRole("status")).toHaveTextContent("Select the text and copy it manually.");
copy.mockRestore();
});
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();

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