Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3caba94dd | ||
|
|
b1723c34c4 | ||
|
|
d6cdffea62 | ||
|
|
49333a2d35 | ||
|
|
b006b94479 | ||
|
|
bdcd8fcd28 | ||
|
|
cf90c1bd51 | ||
|
|
571a4bcaa2 | ||
|
|
9051463654 | ||
|
|
26c5605ff7 | ||
|
|
3535fda958 | ||
|
|
2953f6b608 | ||
|
|
45db3a239b | ||
|
|
7d826e46c0 | ||
|
|
648434a32e | ||
|
|
023b822f83 | ||
|
|
d8a29bfbdd |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Generated
+7
@@ -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,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",
|
||||
|
||||
@@ -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()] };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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></>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user