feat(frontend): phase progress dots (B) + compact sidebar redesign (C)

B — WorkflowBar renders F1..F8 as colored ring-dots (no phase-name text):
green=done, amber=running (subtle pulse), red=error, gray=pending; green
connectors lead the active dot, each dot carries data-state. Lightweight
error signal: sessionStore gains `phaseError`, set when an info event has
level=error during the active phase, cleared on the next ui_request.

C — denser single-line session rows (inline status dot + name, py-1), a
3-level type hierarchy (L1 SESSIONS / L2 section+group headers / L3 names),
and the "No group" label removed (ungrouped render after the last group,
guarded so the empty-state still teaches when there are zero groups).

Live-verified with Playwright (all four dot states, sidebar hierarchy, and
E's deferred activity-panel check). Frontend 93/93, tsc -b clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 17:53:29 +02:00
co-authored by Claude Opus 4.8
parent 2e09c88f78
commit b056ff334f
8 changed files with 160 additions and 68 deletions
@@ -36,6 +36,19 @@ test("active list shows group header and hides archived sessions", async () => {
expect(screen.queryByText("Archiviata due")).not.toBeInTheDocument(); // archived hidden
});
test("ungrouped sessions render after groups with no 'No group' label", async () => {
server.use(
http.get("http://localhost:8787/sessions", () => HttpResponse.json([
{ id: "g1", status: "open", question: "In gruppo", summary: null, created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: null, group: "Aritmologia", archived: false },
{ id: "u1", status: "open", question: "Senza gruppo", summary: null, created_at: "2026-01-03T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: false },
])),
);
wrap();
expect(await screen.findByText("In gruppo")).toBeInTheDocument(); // grouped
expect(screen.getByText("Senza gruppo")).toBeInTheDocument(); // ungrouped, still listed
expect(screen.queryByText(/no group/i)).not.toBeInTheDocument();
});
test("opening the panel shows the session documents", async () => {
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
+15 -10
View File
@@ -50,6 +50,7 @@ export function AppShell() {
[sessions],
);
const activeList = sessions.filter((s) => !s.archived);
const ungroupedActive = activeList.filter((s) => !s.group);
const archivedList = sessions.filter((s) => s.archived);
const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] });
@@ -227,17 +228,19 @@ export function AppShell() {
</Button>
</div>
{/* L1 — rail title */}
<div className="px-4 pb-1.5 pt-1">
<span className="text-[0.7rem] font-bold uppercase tracking-[0.12em] text-primary">
<span className="text-[0.82rem] font-bold uppercase tracking-[0.18em] text-primary">
Sessions
</span>
</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="flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-[0.7rem] font-bold uppercase tracking-wide text-primary hover:text-primary/80"
className="flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-[0.7rem] font-semibold uppercase tracking-[0.1em] text-foreground/65 hover:text-foreground"
>
<span className="select-none">{activeOpen ? "▾" : "▸"}</span>
<span>Active sessions</span>
@@ -247,11 +250,12 @@ export function AppShell() {
{groups.map((g) => (
<div key={g}>
<div className="group/gh flex items-center gap-1">
{/* L2 — group header (sub-level of the section) */}
<button
type="button"
onClick={() => setCollapsedGroups((c) => ({ ...c, [g]: !c[g] }))}
aria-expanded={!collapsedGroups[g]}
className="flex min-w-0 flex-1 items-center gap-1 px-1 pb-1 text-left text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
className="flex min-w-0 flex-1 items-center gap-1 px-1 pb-1 text-left text-[0.66rem] font-semibold uppercase tracking-[0.06em] text-muted-foreground hover:text-foreground"
>
<span className="select-none">{collapsedGroups[g] ? "▸" : "▾"}</span>
<span className="truncate">{g}</span>
@@ -275,25 +279,26 @@ export function AppShell() {
)}
</div>
))}
<div>
{groups.length > 0 && (
<p className="px-1 pb-1 text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">No group</p>
)}
{/* Ungrouped sessions list directly after the groups — no "No group" label.
When there are no groups at all, still render it so its empty state can
teach first-time users. */}
{(ungroupedActive.length > 0 || groups.length === 0) && (
<NavSessions
sessions={activeList.filter((s) => !s.group)}
sessions={ungroupedActive}
activeSessionId={activeSessionId}
onOpenPanel={openPanel}
menuFor={menuFor}
/>
</div>
)}
</div>
)}
{/* L2 — section toggle */}
<button
type="button"
onClick={() => setArchiveOpen((v) => !v)}
aria-expanded={archiveOpen}
className="mt-2 flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-[0.7rem] font-bold uppercase tracking-wide text-muted-foreground hover:text-foreground"
className="mt-2 flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-[0.7rem] font-semibold uppercase tracking-[0.1em] text-foreground/65 hover:text-foreground"
>
<span className="select-none">{archiveOpen ? "▾" : "▸"}</span>
<span>Archive ({archivedList.length})</span>
+25 -26
View File
@@ -18,9 +18,10 @@ export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor }:
);
}
return (
<ul className="flex flex-col gap-0.5">
<ul className="flex flex-col gap-px">
{sessions.map((s) => {
const active = s.id === activeSessionId;
const label = s.name || s.question || s.id;
return (
<li key={s.id}>
<div
@@ -29,7 +30,8 @@ export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor }:
onClick={() => onOpenPanel(s.id)}
role="button"
tabIndex={0}
aria-label={s.name || s.question || s.id}
aria-label={label}
title={`${label} · ${s.status}`}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
@@ -37,33 +39,30 @@ export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor }:
}
}}
className={[
"group flex w-full cursor-pointer items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40",
"group flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40",
active ? "bg-[oklch(var(--primary)/0.12)]" : "hover:bg-accent",
].join(" ")}
>
<div className="min-w-0 flex-1">
<span
className={[
"block truncate text-[0.8rem] leading-snug",
active ? "font-bold text-primary" : "text-foreground/90",
].join(" ")}
>
{s.name || s.question || s.id}
</span>
<span className="mt-1 flex items-center gap-1.5">
<span
className={[
"size-1.5 rounded-full",
s.status === "open"
? "bg-[oklch(var(--success))]"
: s.status === "finalized"
? "bg-primary"
: "bg-muted-foreground/50",
].join(" ")}
/>
<span className="text-[0.65rem] uppercase tracking-wide text-muted-foreground">{s.status}</span>
</span>
</div>
<span
aria-hidden
title={s.status}
className={[
"size-1.5 shrink-0 rounded-full",
s.status === "open"
? "bg-[oklch(var(--success))]"
: s.status === "finalized"
? "bg-primary"
: "bg-muted-foreground/50",
].join(" ")}
/>
<span
className={[
"min-w-0 flex-1 truncate text-[0.8rem] leading-snug",
active ? "font-semibold text-primary" : "font-medium text-foreground/90",
].join(" ")}
>
{label}
</span>
{menuFor(s)}
</div>
</li>
+16
View File
@@ -20,3 +20,19 @@ test("no phase highlighted when currentPhase is null", () => {
expect(screen.getByTestId(`phase-${id}`)).toHaveAttribute("data-active", "false");
}
});
test("dots carry done / running / pending states around the active phase", () => {
useSessionStore.getState().setPhase("F3");
render(<WorkflowBar />);
expect(screen.getByTestId("phase-F1")).toHaveAttribute("data-state", "done");
expect(screen.getByTestId("phase-F3")).toHaveAttribute("data-state", "running");
expect(screen.getByTestId("phase-F5")).toHaveAttribute("data-state", "pending");
});
test("the active dot turns to the error state when phaseError flags it", () => {
const st = useSessionStore.getState();
st.applyEvent({ type: "ui_request", ui_request: { id: "u", widget: "select", phase: "F3_x" } });
st.applyEvent({ type: "info", level: "error", text: "boom" });
render(<WorkflowBar />);
expect(screen.getByTestId("phase-F3")).toHaveAttribute("data-state", "error");
});
+40 -22
View File
@@ -12,8 +12,21 @@ const PHASES = [
{ id: "F8", name: "Results" },
];
type DotState = "done" | "running" | "error" | "pending";
// Each state is a tinted chip; red is reserved strictly for error (the GSD theme's
// primary is already red), green = done, amber = the live phase, neutral = pending.
const DOT: Record<DotState, string> = {
done: "bg-[oklch(var(--success)/0.16)] text-[oklch(0.5_0.13_165)] ring-[oklch(var(--success)/0.32)]",
running:
"bg-[oklch(var(--warning)/0.24)] text-[oklch(0.46_0.11_79)] ring-[oklch(var(--warning)/0.6)]",
error: "bg-destructive/15 text-destructive ring-destructive/45",
pending: "bg-transparent text-muted-foreground/70 ring-border",
};
export function WorkflowBar() {
const currentPhase = useSessionStore((s) => s.currentPhase);
const phaseError = useSessionStore((s) => s.phaseError);
const activeIdx = PHASES.findIndex((p) => p.id === currentPhase);
return (
@@ -24,43 +37,48 @@ export function WorkflowBar() {
{PHASES.map((p, i) => {
const isActive = currentPhase === p.id;
const isDone = activeIdx > -1 && i < activeIdx;
const state: DotState = isActive
? phaseError === p.id
? "error"
: "running"
: isDone
? "done"
: "pending";
const connectorDone = activeIdx > -1 && i <= activeIdx;
return (
<div key={p.id} className="flex items-center">
{i > 0 && (
<span
aria-hidden
className={[
"mx-1.5 h-px w-4 shrink-0",
isDone || isActive ? "bg-primary/40" : "bg-border",
"mx-1 h-px w-3 shrink-0 sm:w-4",
connectorDone ? "bg-[oklch(var(--success)/0.4)]" : "bg-border",
].join(" ")}
/>
)}
<span
data-testid={`phase-${p.id}`}
data-active={isActive ? "true" : "false"}
title={`${p.id}: ${p.name}`}
className={[
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-xs transition-colors",
isActive
? "bg-primary text-primary-foreground font-semibold shadow-sm shadow-primary/20"
: isDone
? "text-foreground/70 font-medium"
: "text-muted-foreground",
].join(" ")}
>
<span className="relative inline-flex shrink-0">
{state === "running" && (
<span
aria-hidden
className="absolute -inset-[3px] animate-pulse rounded-full bg-[oklch(var(--warning)/0.22)]"
/>
)}
<span
data-testid={`phase-${p.id}`}
data-active={isActive ? "true" : "false"}
data-state={state}
title={`${p.id}: ${p.name}`}
aria-label={`${p.id} ${p.name} — ${state}`}
aria-current={isActive ? "step" : undefined}
className={[
"font-heading text-[0.7rem] font-semibold",
isActive
? "text-primary-foreground"
: isDone
? "text-primary/80"
: "text-muted-foreground/70",
"relative inline-flex size-6 items-center justify-center rounded-full",
"text-[0.625rem] font-semibold leading-none tracking-tight tabular-nums",
"ring-1 ring-inset transition-colors",
DOT[state],
].join(" ")}
>
{p.id}
</span>
<span className={isActive ? "inline" : "hidden md:inline"}>{p.name}</span>
</span>
</div>
);