From 2953f6b608cbee303286e186ecca92c0dea66273 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 16:22:20 +0200 Subject: [PATCH] fix(ui): unify Session navigation and restore uniform tab borders --- DESIGN.md | 11 +++- PROJECT_STATE.md | 17 ++++++- docs/guida-utente.md | 6 ++- .../2026-09-13-header-layout-refinements.md | 30 +++++++++++ .../e2e/database-management-layout.spec.ts | 4 +- frontend/e2e/f1.spec.ts | 2 +- frontend/e2e/ui-visual-review.spec.ts | 50 ++++++++++++++++--- frontend/src/App.test.tsx | 4 +- frontend/src/i18n/locales/it-core.ts | 2 + .../shell/AppShell.administration.test.tsx | 12 ++--- frontend/src/shell/AppShell.auth.test.tsx | 2 +- .../AppShell.database-management.test.tsx | 21 +++++--- .../src/shell/AppShell.new-session.test.tsx | 22 +++++--- .../src/shell/AppShell.session-mgmt.test.tsx | 18 ++++--- frontend/src/shell/AppShell.tsx | 20 +++++--- frontend/src/shell/NavSessions.tsx | 2 +- .../src/shell/host/AppShell.host.test.tsx | 10 ++-- 17 files changed, 174 insertions(+), 59 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 04e14b7a..58095da7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 37b35e64..7d80ad19 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -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`. diff --git a/docs/guida-utente.md b/docs/guida-utente.md index 5cfe8d0c..c7af470f 100644 --- a/docs/guida-utente.md +++ b/docs/guida-utente.md @@ -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 diff --git a/docs/reports/2026-09-13-header-layout-refinements.md b/docs/reports/2026-09-13-header-layout-refinements.md index 79924f85..5b00447f 100644 --- a/docs/reports/2026-09-13-header-layout-refinements.md +++ b/docs/reports/2026-09-13-header-layout-refinements.md @@ -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. diff --git a/frontend/e2e/database-management-layout.spec.ts b/frontend/e2e/database-management-layout.spec.ts index 69e6a66f..d3b4d833 100644 --- a/frontend/e2e/database-management-layout.spec.ts +++ b/frontend/e2e/database-management-layout.spec.ts @@ -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 }); diff --git a/frontend/e2e/f1.spec.ts b/frontend/e2e/f1.spec.ts index 73d55e66..0d382351 100644 --- a/frontend/e2e/f1.spec.ts +++ b/frontend/e2e/f1.spec.ts @@ -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"); diff --git a/frontend/e2e/ui-visual-review.spec.ts b/frontend/e2e/ui-visual-review.spec.ts index 5c4236af..4c74819d 100644 --- a/frontend/e2e/ui-visual-review.spec.ts +++ b/frontend/e2e/ui-visual-review.spec.ts @@ -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) => { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 2ccbc0a8..984e61f1 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -46,7 +46,7 @@ test("local authentication renders the standalone logout control", async () => { render(); - 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(); }); diff --git a/frontend/src/i18n/locales/it-core.ts b/frontend/src/i18n/locales/it-core.ts index 9feebd19..b197eab9 100644 --- a/frontend/src/i18n/locales/it-core.ts +++ b/frontend/src/i18n/locales/it-core.ts @@ -86,6 +86,8 @@ export const itCore: Record = { "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", diff --git a/frontend/src/shell/AppShell.administration.test.tsx b/frontend/src/shell/AppShell.administration.test.tsx index 27486efc..56e25d18 100644 --- a/frontend/src/shell/AppShell.administration.test.tsx +++ b/frontend/src/shell/AppShell.administration.test.tsx @@ -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(); }); diff --git a/frontend/src/shell/AppShell.auth.test.tsx b/frontend/src/shell/AppShell.auth.test.tsx index 0ae00502..4eedcf06 100644 --- a/frontend/src/shell/AppShell.auth.test.tsx +++ b/frontend/src/shell/AppShell.auth.test.tsx @@ -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(); diff --git a/frontend/src/shell/AppShell.database-management.test.tsx b/frontend/src/shell/AppShell.database-management.test.tsx index 50d457df..8239d35a 100644 --- a/frontend/src/shell/AppShell.database-management.test.tsx +++ b/frontend/src/shell/AppShell.database-management.test.tsx @@ -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(); diff --git a/frontend/src/shell/AppShell.new-session.test.tsx b/frontend/src/shell/AppShell.new-session.test.tsx index ca8f73f6..911dcc4a 100644 --- a/frontend/src/shell/AppShell.new-session.test.tsx +++ b/frontend/src/shell/AppShell.new-session.test.tsx @@ -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); diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 81084472..23c7abd3 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -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(); diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 5e1ae3dd..96a943de 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -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) {
- {activeSurface !== "core" && } + disabled={sessionActionUnavailable} + title={sessionActionUnavailable && preprocessingBlocksNewSession ? preprocessingQuery.data?.detail : undefined} + onClick={() => { + if (canReturnToCurrentSession) navigate({ surface: "core" }); + else startNewSession(); + }} + >{t("Session")} {isAdmin && (