fix(ui): unify Session navigation and restore uniform tab borders

This commit is contained in:
Codex
2026-09-13 16:22:20 +02:00
parent 45db3a239b
commit 2953f6b608
17 changed files with 174 additions and 59 deletions
+9 -2
View File
@@ -165,7 +165,9 @@ The full-mode application header matches Omics Portal's `--gsd-red-primary`
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 without a bottom border.
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
@@ -306,6 +308,9 @@ default, hover, focus, active, disabled, loading, and error behavior where those
### Navigation
- **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.
@@ -324,7 +329,9 @@ default, hover, focus, active, disabled, loading, and error behavior where those
### Tabs
- **Shape:** compact label tabs sit on a shared baseline with rounded top corners and a two-pixel
lower edge, except session-scope tabs which have no bottom border. Inactive labels retain a 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
+16 -1
View File
@@ -48,6 +48,20 @@ PSD Gitea push and production deployment are still pending.
## Current product shape
### 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;
@@ -333,7 +347,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 -2
View File
@@ -7,7 +7,7 @@ the end of this page.
## 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 +15,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
@@ -73,3 +73,33 @@ Solo il frontend Mac è stato ricreato, healthy: container `204efd40d3eb`, immag
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.
@@ -409,7 +409,7 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
await page.goto(stack.publicUrl);
await signInAsAdmin(page);
await expectCurrentNavigation(page, "New session");
await expectCurrentNavigation(page, "Session");
await expectSelectedSessionScopeTab(page, "My sessions");
await page.getByRole("tab", { name: "All sessions", exact: true }).click();
await expectSelectedSessionScopeTab(page, "All sessions");
@@ -808,7 +808,7 @@ test("Workspace and Pi management share the centered work-area panel without cov
.getByRole("button", { name: "Close Pi management" })
.click();
await expect(piTrigger).toBeFocused();
await expectCurrentNavigation(page, "New session");
await expectCurrentNavigation(page, "Session");
}
await page.setViewportSize({ width: 720, height: 800 });
+1 -1
View File
@@ -39,7 +39,7 @@ test("F1 loop: new question → F1 widget → respond", async ({ page }) => {
await signInLocally(page);
// Focus the composer for a new session.
await page.getByRole("button", { name: "New session", exact: true }).click();
await page.getByRole("button", { name: "Session", exact: true }).click();
// Fill in the question.
await page.getByLabel("New question").fill("quante cardioversioni nel 2024");
+43 -7
View File
@@ -135,7 +135,7 @@ for (const width of [390, 1280]) {
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", "0px");
await expect(page.getByRole("tab", { name, exact: true })).toHaveCSS("border-bottom-width", "1px");
}
}
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
@@ -162,6 +162,42 @@ for (const width of [390, 1280]) {
});
}
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);
@@ -202,7 +238,7 @@ for (const route of ["/", "/?thoth_route=administration%2Fdatabase"]) {
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();
@@ -234,14 +270,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)).toBe(emptyHeight);
expect(writes).toEqual([]);
expect(writes).toEqual(route === "/" ? ["/runtime/prewarm"] : ["/runtime/prewarm", "/runtime/prewarm"]);
});
}
@@ -361,13 +397,13 @@ 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) => {
+2 -2
View File
@@ -46,7 +46,7 @@ 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();
await userEvent.click(within(screen.getByRole("banner", { name: "Application header" })).getByRole("button", { name: "Portal user" }));
expect(await screen.findByRole("menuitem", { name: "Log out" })).toBeInTheDocument();
@@ -113,7 +113,7 @@ test("a local login always enters the core activity", async () => {
await userEvent.type(screen.getByLabelText(/^password$/i), "correct-password");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByRole("button", { name: "New session" }))
expect(await screen.findByRole("button", { name: "Session" }))
.toHaveAttribute("aria-current", "page");
expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument();
});
+2
View File
@@ -86,6 +86,8 @@ export const itCore: Record<string, string> = {
"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",
@@ -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);
@@ -138,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");
});
@@ -149,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");
@@ -193,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");
});
@@ -245,12 +245,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();
});
+1 -1
View File
@@ -118,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();
@@ -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",
@@ -185,13 +186,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 +215,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 +263,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);
@@ -136,7 +136,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");
@@ -147,7 +147,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");
});
@@ -160,7 +162,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();
@@ -190,8 +192,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-b-0");
expect(allSessions).toHaveClass("border-b-0");
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");
@@ -403,7 +407,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();
});
@@ -822,7 +826,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();
+12 -8
View File
@@ -61,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-0 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`;
@@ -638,6 +638,8 @@ export function AppShell({ canLogout }: AppShellProps) {
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).
@@ -965,19 +967,21 @@ export function AppShell({ canLogout }: AppShellProps) {
</div>
<div className="thot-session-navigation__primary-controls">
{activeSurface !== "core" && <Button variant="outline" size="sm" className="w-full" onClick={() => navigate({ surface: "core" })}>{t("Return to session")}</Button>}
<Button
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
size="sm"
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}
>{t("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}
@@ -1075,7 +1079,7 @@ export function AppShell({ canLogout }: AppShellProps) {
role="tablist"
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; }}
+1 -1
View File
@@ -29,7 +29,7 @@ export function NavSessions({
if (sessions.length === 0) {
return (
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
{translate("No sessions yet.")} <span className="font-medium text-foreground">{translate("Start with New session.")}</span>
{translate("No sessions yet.")} <span className="font-medium text-foreground">{translate("Start with Session.")}</span>
</p>
);
}
@@ -66,7 +66,7 @@ test("full controls change locale/theme and follow fullscreen entry, exit, Esc,
expect(within(header).getByRole("button", { name: "Analyst" })).toBeInTheDocument();
expect(within(header).queryByRole("button", { name: /admin|settings/i })).not.toBeInTheDocument();
await userEvent.selectOptions(within(header).getByRole("combobox", { name: "Interface language" }), "it");
expect(await screen.findByRole("button", { name: "Nuova sessione" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Sessione" })).toBeInTheDocument();
await userEvent.selectOptions(within(header).getByRole("combobox", { name: "Lingua dell’interfaccia" }), "en");
await userEvent.click(within(header).getByRole("button", { name: "Use dark theme" }));
expect(document.documentElement).toHaveAttribute("data-bs-theme", "dark");
@@ -130,7 +130,7 @@ test("legacy embedded consumes the portal state and supplies no duplicate contro
select.dataset.lang = "it";
document.body.append(select);
render(<App />);
expect(await screen.findByRole("button", { name: "Nuova sessione" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Sessione" })).toBeInTheDocument();
expect(screen.queryByRole("banner", { name: /application|intestazione/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /fullscreen|tema|Log out/i })).not.toBeInTheDocument();
act(() => { document.documentElement.setAttribute("data-bs-theme", "light"); });
@@ -160,8 +160,8 @@ test("reload restores only an authorized session document panel without resuming
expect(resume).not.toHaveBeenCalled();
expect(FakeEventSource.instances).toHaveLength(0);
expect(readRememberedSession(user)).toBe("saved");
await waitFor(() => expect(screen.getByRole("button", { name: "New session" })).toBeEnabled());
await userEvent.click(screen.getByRole("button", { name: "New session" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Session" })).toBeEnabled());
await userEvent.click(screen.getByRole("button", { name: "Session" }));
expect(readRememberedSession(user)).toBeNull();
});
@@ -172,7 +172,7 @@ test("reload ignores a session absent from the authorized list and isolates prin
expect(readRememberedSession(user)).toBeNull();
window.history.replaceState(null, "", "/");
render(<App />);
await screen.findByRole("button", { name: "New session" });
await screen.findByRole("button", { name: "Session" });
await waitFor(() => expect(readRememberedSession(user)).toBeNull());
expect(screen.queryByRole("button", { name: "Resume" })).not.toBeInTheDocument();
expect(FakeEventSource.instances).toHaveLength(0);