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
+40
View File
@@ -319,6 +319,46 @@ body.thot-full-document { margin: 0; }
padding: 0 1rem 0.75rem;
}
.thot-session-accordion { display: flex; flex-direction: column; }
.thot-session-accordion__item {
display: flex;
flex-direction: column;
flex: 0 0 auto;
border-top: 1px solid oklch(var(--border));
}
.thot-session-accordion__item[data-open] {
flex: 1 1 0;
min-height: 2.5rem;
max-height: calc(min(18rem, 35dvh) + 2.5rem);
}
.thot-session-accordion__item > h3 { flex: none; }
.thot-session-accordion__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
min-height: 2.5rem;
padding: 0.5rem;
font: 600 var(--text-control)/1.5 var(--font-sans);
text-align: left;
}
.thot-session-accordion__trigger:hover { background: oklch(var(--muted)); }
.thot-session-accordion__panel {
flex: 1 1 auto;
max-height: min(18rem, 35dvh);
min-height: 0;
overflow-y: auto;
overscroll-behavior-y: contain;
scrollbar-gutter: stable;
padding: 0.25rem;
}
.thot-session-accordion__trigger:focus-visible,
.thot-session-accordion__panel:focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: -2px;
}
/* UI labels use the same family as controls; monospace is for technical data. */
.thot-label {
font-family: var(--font-sans);
+3 -1
View File
@@ -159,7 +159,9 @@ describe("authenticated shell permissions", () => {
expect(panel.children[4]).toBe(workspace);
expect(panel.children[5]).toBe(pi);
expect(within(panel).queryByRole("region", { name: "Workspace preprocessing" })).not.toBeInTheDocument();
expect(within(panel).getByText(/Workspace readiness/)).toBeInTheDocument();
expect(within(panel).queryByText(/Workspace readiness/)).not.toBeInTheDocument();
expect(within(workspace).getByRole("img", { name: /Workspace readiness/ })).toBeInTheDocument();
expect(workspace).toHaveAccessibleDescription(/Workspace readiness/);
expect(screen.getByRole("tab", { name: "All sessions" })).toBeInTheDocument();
await user.keyboard(" ");
@@ -423,6 +423,26 @@ test("Archive accordion expands to reveal archived sessions", async () => {
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 () => {
let resumed: string | null = null;
useSessionStore.setState({
+35 -31
View File
@@ -193,6 +193,10 @@ export function AppShell({ canLogout }: AppShellProps) {
&& preprocessingQuery.data
&& 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[]>({
queryKey: ["sessions", sessionScope],
queryFn: async () => {
@@ -951,7 +955,7 @@ export function AppShell({ canLogout }: AppShellProps) {
{/* Right session rail */}
{(
<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">
<h1 className="thot-wordmark--sidebar font-heading text-[2rem] font-semibold leading-none tracking-tight text-foreground">
Thoth<span className="text-primary">II</span>
@@ -1046,12 +1050,16 @@ export function AppShell({ canLogout }: AppShellProps) {
size="sm"
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "workspace" ? "page" : undefined}
aria-label={t("Workspace")}
aria-describedby="workspace-readiness-indicator"
title={workspaceReadinessLabel}
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
navigate({ surface: "workspace-management" }, true);
}}
>{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
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
size="sm"
@@ -1065,8 +1073,6 @@ export function AppShell({ canLogout }: AppShellProps) {
navigate({ surface: "pi-management" }, true);
}}
>{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.Item>
</Accordion.Root>
@@ -1154,18 +1160,18 @@ export function AppShell({ canLogout }: AppShellProps) {
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto px-2 pb-4">
{/* L2 — section toggle */}
<button
type="button"
onClick={() => setActiveOpen((v) => !v)}
aria-expanded={activeOpen}
className="thot-label flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-foreground/65 hover:text-foreground"
>
<span className="select-none">{activeOpen ? "▾" : "▸"}</span>
<span>{t("Active sessions")}</span>
</button>
{activeOpen && (
<Accordion.Root multiple
value={[...(activeOpen ? ["active"] : []), ...(archiveOpen ? ["archive"] : [])]}
onValueChange={value => { setActiveOpen(value.includes("active")); setArchiveOpen(value.includes("archive")); }}
className="thot-session-accordion min-h-0 flex-1 overflow-y-auto px-2 pb-4">
<Accordion.Item value="active" className="thot-session-accordion__item">
<Accordion.Header className="m-0">
<Accordion.Trigger className="thot-session-accordion__trigger">
<span>{t("Active sessions")}</span>
<ChevronDown aria-hidden="true" className={`size-4 shrink-0 ${activeOpen ? "rotate-180" : ""}`} />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel className="thot-session-accordion__panel" tabIndex={0}>
<div className="flex flex-col gap-3 pb-2">
{groups.map((g) => (
<div key={g}>
@@ -1217,19 +1223,16 @@ export function AppShell({ canLogout }: AppShellProps) {
/>
)}
</div>
)}
{/* L2 — section toggle */}
<button
type="button"
onClick={() => setArchiveOpen((v) => !v)}
aria-expanded={archiveOpen}
className="thot-label mt-2 flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-foreground/65 hover:text-foreground"
>
<span className="select-none">{archiveOpen ? "▾" : "▸"}</span>
<span className="tabular-nums">{t("Archive ({count})", { count: archivedList.length })}</span>
</button>
{archiveOpen && (
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="archive" className="thot-session-accordion__item">
<Accordion.Header className="m-0">
<Accordion.Trigger className="thot-session-accordion__trigger">
<span className="tabular-nums">{t("Archive ({count})", { count: archivedList.length })}</span>
<ChevronDown aria-hidden="true" className={`size-4 shrink-0 ${archiveOpen ? "rotate-180" : ""}`} />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel className="thot-session-accordion__panel" tabIndex={0}>
<NavSessions
sessions={archivedList}
activeSessionId={activeSessionId}
@@ -1239,8 +1242,9 @@ export function AppShell({ canLogout }: AppShellProps) {
onSelectionChange={setSessionSelected}
showOwner={showingAllSessions}
/>
)}
</div>
</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
</div>
</aside>
</ArchiveNavigation>
+2 -2
View File
@@ -32,10 +32,10 @@ export function ArchiveNavigation({ surface, forceDrawer = false, children }: {
<span className="font-heading font-semibold">ThothII</span>
<DialogTrigger render={<Button variant="outline" size="sm" />}>{translate("Navigation")}</DialogTrigger>
</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>
<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>
</Dialog></>;
}