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:
+18
-9
@@ -81,7 +81,8 @@ Opens frontend at http://localhost:5173 → backend :8787.
|
||||
## UI/UX redesign + Resume — IN PROGRESS (2026-06-30, evening)
|
||||
|
||||
Approved multi-workstream plan: **`~/.claude/plans/foamy-forging-dahl.md`** (read it to resume).
|
||||
Memory: `thothii-ui-redesign-inprogress.md`. **D + E are merged to `main` @ `0eeb3f7` and pushed.**
|
||||
Memory: `thothii-ui-redesign-inprogress.md`. **D + E merged @ `0eeb3f7` (pushed); B + C
|
||||
implemented + live-verified (not yet committed as of this update).**
|
||||
|
||||
- **D — DONE** (`c12bdcd`): session display `name` = 3-5 Italian keywords via **YAKE** (no LLM),
|
||||
derived in `tht session new` (CLI layer); `create_session` core unchanged (`name=None` default).
|
||||
@@ -89,20 +90,28 @@ Memory: `thothii-ui-redesign-inprogress.md`. **D + E are merged to `main` @ `0ee
|
||||
- **E — DONE** (`0eeb3f7`): rotating activity icon replaces the red dot in `CentralStatus`
|
||||
(inline, clickable → opens the panel); `ModelActivityPanel` is a **5-line expandable
|
||||
model-stream tail**; `WorkingSpinner` extracted to its own module; the separate spinner button
|
||||
+ orphaned `Transcript.tsx` removed. Frontend 87/87, tsc clean. **Live visual check PENDING**
|
||||
(needs a *working* session — do it in the B/C pass).
|
||||
- **B (pending):** `WorkflowBar` → colored **dots** F1..F8, no text (amber-translucent=running,
|
||||
green=done, red=error, gray=pending); error is lightweight (store `phaseError` set on an `info`
|
||||
`level=error` during the phase, cleared on the next `ui_request`).
|
||||
- **C (pending):** right sidebar — tighter rows, 3-level type hierarchy via **`/impeccable`**,
|
||||
remove the "No group" label (ungrouped after the last group accordion); add **Resume** to `SessionMenu`.
|
||||
+ orphaned `Transcript.tsx` removed. Frontend 87/87, tsc clean. **Live visual check DONE
|
||||
(2026-06-30):** inline spinner opens the panel; 5-line collapsed tail; expand → full transcript.
|
||||
- **B — DONE** (uncommitted): `WorkflowBar` is now colored **dots** F1..F8, no phase-name text
|
||||
(amber-translucent=running, green=done, red=error, gray=pending; green connectors lead the active
|
||||
dot). Each dot carries `data-state`. Error is lightweight: store `phaseError` set when an `info`
|
||||
`level=error` arrives during the phase, cleared on the next `ui_request` (`sessionStore.ts`).
|
||||
**All four states live-verified** via Playwright.
|
||||
- **C — DONE** (uncommitted): right sidebar — single-line denser rows (inline status dot + name,
|
||||
`py-1`), a 3-level type hierarchy via **`/impeccable`** (L1 `SESSIONS` red/bold/wide-tracking ·
|
||||
L2 section + group headers muted uppercase · L3 names normal-case), and the **"No group" label
|
||||
removed** (ungrouped sessions render after the last group; guarded so the empty-state still
|
||||
teaches when there are no groups). **Live-verified.** (Resume in `SessionMenu` stays with A1.)
|
||||
- **Tests:** frontend **93/93** (was 87; +3 store `phaseError`, +2 `WorkflowBar` dot-state, +1
|
||||
AppShell no-"No group"), `tsc -b` clean.
|
||||
- **F (pending):** single-select answers **auto-confirm** — `reviewer_select` persists the
|
||||
decision directly on a concrete choice (no redundant `reviewer_decide` gate); contract change
|
||||
(update its tool desc + `SKILL.md` + the `CLAUDE.md` note). back/exit/Other stay non-persisting.
|
||||
- **A (pending, riskiest):** Resume command + **FIX the resume cold-start stall** (open item #1).
|
||||
- **G (later):** cross-model behavior matrix (Qwen3.6 / GLM 5.2 / Deepseek V4 / others).
|
||||
|
||||
**Next chunk:** the **B/C visual pass** (`/impeccable` + a live verification, incl. E's deferred check).
|
||||
**Next chunk:** **F** (single-select auto-confirm — harness gate/SKILL contract change), then **A**
|
||||
(Resume in the kebab + the resume cold-start stall fix, diagnosis-first). **G** (cross-model) later.
|
||||
|
||||
## Live verification + reviewer_select fix (2026-06-30, afternoon)
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -54,6 +54,30 @@ test("info events accumulate in stepMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("an error-level info event flags the current phase", () => {
|
||||
const st = useSessionStore.getState();
|
||||
st.applyEvent({ type: "ui_request", ui_request: { id: "u", widget: "select", phase: "F4_schema_linking" } });
|
||||
st.applyEvent({ type: "info", level: "error", text: "boom" });
|
||||
expect(useSessionStore.getState().phaseError).toBe("F4");
|
||||
// the message still lands in stepMessages
|
||||
expect(useSessionStore.getState().stepMessages.at(-1)).toEqual({ level: "error", text: "boom" });
|
||||
});
|
||||
|
||||
test("a non-error info event does not set phaseError", () => {
|
||||
const st = useSessionStore.getState();
|
||||
st.applyEvent({ type: "ui_request", ui_request: { id: "u", widget: "select", phase: "F4_x" } });
|
||||
st.applyEvent({ type: "info", level: "warning", text: "w" });
|
||||
expect(useSessionStore.getState().phaseError).toBeNull();
|
||||
});
|
||||
|
||||
test("the next ui_request clears phaseError", () => {
|
||||
const st = useSessionStore.getState();
|
||||
st.applyEvent({ type: "ui_request", ui_request: { id: "u", widget: "select", phase: "F4_x" } });
|
||||
st.applyEvent({ type: "info", level: "error", text: "boom" });
|
||||
st.applyEvent({ type: "ui_request", ui_request: { id: "u2", widget: "select", phase: "F5_x" } });
|
||||
expect(useSessionStore.getState().phaseError).toBeNull();
|
||||
});
|
||||
|
||||
test("setLastUserEntry records the entry and clears stepMessages", () => {
|
||||
const st = useSessionStore.getState();
|
||||
st.applyEvent({ type: "info", level: "info", text: "x" });
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SessionState {
|
||||
lastUserEntry: { kind: "input" | "choice"; text: string } | null;
|
||||
lastSystemEvent: StreamEvent | null;
|
||||
currentPhase: string | null;
|
||||
phaseError: string | null;
|
||||
applyEvent: (e: StreamEvent) => void;
|
||||
clearPending: () => void;
|
||||
resetSession: () => void;
|
||||
@@ -30,6 +31,7 @@ const empty = {
|
||||
lastUserEntry: null as { kind: "input" | "choice"; text: string } | null,
|
||||
lastSystemEvent: null,
|
||||
currentPhase: null as string | null,
|
||||
phaseError: null as string | null,
|
||||
};
|
||||
|
||||
export const useSessionStore = create<SessionState>((set) => ({
|
||||
@@ -42,6 +44,8 @@ export const useSessionStore = create<SessionState>((set) => ({
|
||||
currentPhase: e.ui_request.phase
|
||||
? e.ui_request.phase.split("_")[0]
|
||||
: st.currentPhase,
|
||||
// A new gate means the phase moved on (or re-presented): clear any error flag.
|
||||
phaseError: null,
|
||||
};
|
||||
if (e.type === "text_delta") {
|
||||
const t = [...st.transcript];
|
||||
@@ -50,7 +54,11 @@ export const useSessionStore = create<SessionState>((set) => ({
|
||||
else t.push({ role: "assistant", text: e.text });
|
||||
return { transcript: t };
|
||||
}
|
||||
if (e.type === "info") return { stepMessages: [...st.stepMessages, { level: e.level ?? "info", text: e.text }] };
|
||||
if (e.type === "info") {
|
||||
const stepMessages = [...st.stepMessages, { level: e.level ?? "info", text: e.text }];
|
||||
// An error during the active phase marks that phase red until the next gate.
|
||||
return e.level === "error" ? { stepMessages, phaseError: st.currentPhase } : { stepMessages };
|
||||
}
|
||||
if (e.type === "system_event") return { lastSystemEvent: e };
|
||||
return {};
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user