fix(ui): show workspace readiness dot and bounded session accordions

This commit is contained in:
Codex
2026-09-13 17:39:33 +02:00
parent 9051463654
commit 571a4bcaa2
9 changed files with 191 additions and 34 deletions
+12
View File
@@ -308,6 +308,18 @@ default, hover, focus, active, disabled, loading, and error behavior where those
### Navigation ### 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 multi-open accordion contains Active sessions
(initially open) and Archive (initially closed). Preserve the existing archived
flag as the grouping rule, independent of whether a Pi process is running.
Open sections share the rail's remaining height; each list scrolls independently
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, - **Session entry:** one Session button returns to the current unfinished session,
including provisional creation, without resetting or reconnecting it. Otherwise including provisional creation, without resetting or reconnecting it. Otherwise
it prepares a new question using the normal readiness and unsaved-work guards. it prepares a new question using the normal readiness and unsaved-work guards.
+21
View File
@@ -39,6 +39,27 @@ for standalone OIDC, not Omics upstream. No runtime configuration or deployment
was changed by this documentation pass. Closing/merging the branch and actual was changed by this documentation pass. Closing/merging the branch and actual
PSD acceptance remain separate, unperformed steps. PSD acceptance remain separate, unperformed steps.
### 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) start expanded; Archive starts collapsed. Both
accordion sections can remain open and share the remaining sidebar height,
with independently 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: 763 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-navigation-accordions-20260913` for rollback.
### Agreed Omics delivery route — 2026-09-13 ### Agreed Omics delivery route — 2026-09-13
The owner approved GitHub as an intermediate transport for Omics: publish only The owner approved GitHub as an intermediate transport for Omics: publish only
+9
View File
@@ -59,6 +59,15 @@ The workflow phases are fixed:
## Resume, archive, and the meaning of saved state ## 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** starts open,
**Archive** starts closed. Both can be open together; 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 The sidebar lists sessions and their current lifecycle. Resuming returns to the last incomplete
phase. A finalized or archived session cannot be resumed. phase. A finalized or archived session cannot be resumed.
+49
View File
@@ -103,6 +103,55 @@ 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", "true");
await expect(archive).toHaveAttribute("aria-expanded", "false");
await archive.click();
for (const name of ["Active sessions", "Archive (40)"]) {
const panel = page.getByRole("region", { name, exact: true });
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]) { for (const width of [390, 1280, 2400]) {
test(`knowledge readers use available width and readable paragraphs at ${width}px`, async ({ page }, testInfo) => { test(`knowledge readers use available width and readable paragraphs at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1100 }); await page.setViewportSize({ width, height: 1100 });
+40
View File
@@ -319,6 +319,46 @@ body.thot-full-document { margin: 0; }
padding: 0 1rem 0.75rem; 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. */ /* UI labels use the same family as controls; monospace is for technical data. */
.thot-label { .thot-label {
font-family: var(--font-sans); font-family: var(--font-sans);
+3 -1
View File
@@ -159,7 +159,9 @@ describe("authenticated shell permissions", () => {
expect(panel.children[4]).toBe(workspace); expect(panel.children[4]).toBe(workspace);
expect(panel.children[5]).toBe(pi); expect(panel.children[5]).toBe(pi);
expect(within(panel).queryByRole("region", { name: "Workspace preprocessing" })).not.toBeInTheDocument(); 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(); expect(screen.getByRole("tab", { name: "All sessions" })).toBeInTheDocument();
await user.keyboard(" "); await user.keyboard(" ");
@@ -423,6 +423,26 @@ test("Archive accordion expands to reveal archived sessions", async () => {
expect(screen.getByText("Attiva uno")).toBeInTheDocument(); expect(screen.getByText("Attiva uno")).toBeInTheDocument();
}); });
test("session accordion exposes labelled panels and supports independent keyboard toggles", async () => {
wrap();
await screen.findByText("Attiva uno");
const active = screen.getByRole("button", { name: "Active sessions" });
const archive = screen.getByRole("button", { name: "Archive (1)" });
expect(active).toHaveAttribute("aria-expanded", "true");
expect(archive).toHaveAttribute("aria-expanded", "false");
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", "true");
active.focus();
await userEvent.keyboard(" ");
expect(active).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("Attiva uno")).not.toBeInTheDocument();
expect(screen.getByText("Archiviata due")).toBeInTheDocument();
});
test("Resume from the panel activates the session and closes the panel", async () => { test("Resume from the panel activates the session and closes the panel", async () => {
let resumed: string | null = null; let resumed: string | null = null;
useSessionStore.setState({ useSessionStore.setState({
+35 -31
View File
@@ -193,6 +193,10 @@ export function AppShell({ canLogout }: AppShellProps) {
&& preprocessingQuery.data && preprocessingQuery.data
&& preprocessingQuery.data.state !== "ready", && preprocessingQuery.data.state !== "ready",
); );
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[]>({ const { data: sessions = [], isSuccess: sessionsLoaded } = useQuery<SessionSummary[]>({
queryKey: ["sessions", sessionScope], queryKey: ["sessions", sessionScope],
queryFn: async () => { queryFn: async () => {
@@ -951,7 +955,7 @@ export function AppShell({ canLogout }: AppShellProps) {
{/* Right session rail */} {/* Right session rail */}
{( {(
<ArchiveNavigation surface={activeSurface} forceDrawer={activeSurface === "core" && showActivity}> <ArchiveNavigation surface={activeSurface} forceDrawer={activeSurface === "core" && showActivity}>
<aside aria-label={t("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"> <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"> <h1 className="thot-wordmark--sidebar font-heading text-[2rem] font-semibold leading-none tracking-tight text-foreground">
Thoth<span className="text-primary">II</span> Thoth<span className="text-primary">II</span>
@@ -1046,12 +1050,16 @@ export function AppShell({ canLogout }: AppShellProps) {
size="sm" size="sm"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "workspace" ? "page" : undefined} aria-current={currentNavigation === "workspace" ? "page" : undefined}
aria-label={t("Workspace")}
aria-describedby="workspace-readiness-indicator"
title={workspaceReadinessLabel}
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"} data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
onClick={() => { onClick={() => {
if (!canLeaveDatabaseManagement()) return; if (!canLeaveDatabaseManagement()) return;
navigate({ surface: "workspace-management" }, true); navigate({ surface: "workspace-management" }, true);
}} }}
>{t("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 <Button
variant={currentNavigation === "pi" ? "navigationActive" : "outline"} variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
size="sm" size="sm"
@@ -1065,8 +1073,6 @@ export function AppShell({ canLogout }: AppShellProps) {
navigate({ surface: "pi-management" }, true); navigate({ surface: "pi-management" }, true);
}} }}
>{t("Pi configuration")}</Button> >{t("Pi configuration")}</Button>
<Button variant="ghost" size="sm" className="w-full min-w-0 whitespace-normal" onClick={() => navigate({ surface: "workspace-management", workspace: selectedWorkspaceId })}>{t("Workspace readiness: {state}", { state: t(preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking") })}
</Button>
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
</Accordion.Root> </Accordion.Root>
@@ -1154,18 +1160,18 @@ export function AppShell({ canLogout }: AppShellProps) {
</Button> </Button>
)} )}
</div> </div>
<div className="flex-1 overflow-y-auto px-2 pb-4"> <Accordion.Root multiple
{/* L2 — section toggle */} value={[...(activeOpen ? ["active"] : []), ...(archiveOpen ? ["archive"] : [])]}
<button onValueChange={value => { setActiveOpen(value.includes("active")); setArchiveOpen(value.includes("archive")); }}
type="button" className="thot-session-accordion min-h-0 flex-1 overflow-y-auto px-2 pb-4">
onClick={() => setActiveOpen((v) => !v)} <Accordion.Item value="active" className="thot-session-accordion__item">
aria-expanded={activeOpen} <Accordion.Header className="m-0">
className="thot-label flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-foreground/65 hover:text-foreground" <Accordion.Trigger className="thot-session-accordion__trigger">
> <span>{t("Active sessions")}</span>
<span className="select-none">{activeOpen ? "▾" : "▸"}</span> <ChevronDown aria-hidden="true" className={`size-4 shrink-0 ${activeOpen ? "rotate-180" : ""}`} />
<span>{t("Active sessions")}</span> </Accordion.Trigger>
</button> </Accordion.Header>
{activeOpen && ( <Accordion.Panel className="thot-session-accordion__panel" tabIndex={0}>
<div className="flex flex-col gap-3 pb-2"> <div className="flex flex-col gap-3 pb-2">
{groups.map((g) => ( {groups.map((g) => (
<div key={g}> <div key={g}>
@@ -1217,19 +1223,16 @@ export function AppShell({ canLogout }: AppShellProps) {
/> />
)} )}
</div> </div>
)} </Accordion.Panel>
</Accordion.Item>
{/* L2 — section toggle */} <Accordion.Item value="archive" className="thot-session-accordion__item">
<button <Accordion.Header className="m-0">
type="button" <Accordion.Trigger className="thot-session-accordion__trigger">
onClick={() => setArchiveOpen((v) => !v)} <span className="tabular-nums">{t("Archive ({count})", { count: archivedList.length })}</span>
aria-expanded={archiveOpen} <ChevronDown aria-hidden="true" className={`size-4 shrink-0 ${archiveOpen ? "rotate-180" : ""}`} />
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" </Accordion.Trigger>
> </Accordion.Header>
<span className="select-none">{archiveOpen ? "▾" : "▸"}</span> <Accordion.Panel className="thot-session-accordion__panel" tabIndex={0}>
<span className="tabular-nums">{t("Archive ({count})", { count: archivedList.length })}</span>
</button>
{archiveOpen && (
<NavSessions <NavSessions
sessions={archivedList} sessions={archivedList}
activeSessionId={activeSessionId} activeSessionId={activeSessionId}
@@ -1239,8 +1242,9 @@ export function AppShell({ canLogout }: AppShellProps) {
onSelectionChange={setSessionSelected} onSelectionChange={setSessionSelected}
showOwner={showingAllSessions} showOwner={showingAllSessions}
/> />
)} </Accordion.Panel>
</div> </Accordion.Item>
</Accordion.Root>
</div> </div>
</aside> </aside>
</ArchiveNavigation> </ArchiveNavigation>
+2 -2
View File
@@ -32,10 +32,10 @@ export function ArchiveNavigation({ surface, forceDrawer = false, children }: {
<span className="font-heading font-semibold">ThothII</span> <span className="font-heading font-semibold">ThothII</span>
<DialogTrigger render={<Button variant="outline" size="sm" />}>{translate("Navigation")}</DialogTrigger> <DialogTrigger render={<Button variant="outline" size="sm" />}>{translate("Navigation")}</DialogTrigger>
</div> </div>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col"> <DialogContent className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col">
<DialogTitle>{translate("Navigation")}</DialogTitle> <DialogTitle>{translate("Navigation")}</DialogTitle>
<DialogDescription className="sr-only">{translate("Choose an administration page or a session.")}</DialogDescription> <DialogDescription className="sr-only">{translate("Choose an administration page or a session.")}</DialogDescription>
<div className="min-h-0 overflow-y-auto [&>aside]:w-full">{children}</div> <div className="min-h-0 flex-1 overflow-y-auto [&>aside]:h-full [&>aside]:w-full">{children}</div>
</DialogContent> </DialogContent>
</Dialog></>; </Dialog></>;
} }