From 5295e348984b81d9a27c9eeca55810ebe25bbd9b Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 28 Jun 2026 13:40:33 +0200 Subject: [PATCH] fix(frontend): wire currentPhase to WorkflowBar (normalized) + WidgetHost error handling - sessionStore.applyEvent: on ui_request set currentPhase normalized from the descriptor's phase to its short id (e.g. "F4_schema_linking" -> "F4"), so WorkflowBar actually highlights the active phase; falls back to the existing phase when the descriptor has none. setPhase kept for resume/getSession. - Add pushToast store action; WidgetHost wraps postResponse in try/catch, pushes an error toast and keeps the widget pending on failure (clearPending only on success) so the user can retry. - Tests: store currentPhase normalization + no-phase passthrough + pushToast; new WorkflowBar.test.tsx asserting the matching phase is highlighted. Co-Authored-By: Claude Opus 4.8 --- frontend/src/shell/WidgetHost.tsx | 17 ++++++++++++++++- frontend/src/shell/WorkflowBar.test.tsx | 22 ++++++++++++++++++++++ frontend/src/store/sessionStore.test.ts | 18 ++++++++++++++++++ frontend/src/store/sessionStore.ts | 10 +++++++++- 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 frontend/src/shell/WorkflowBar.test.tsx diff --git a/frontend/src/shell/WidgetHost.tsx b/frontend/src/shell/WidgetHost.tsx index 068b6d96..0436e357 100644 --- a/frontend/src/shell/WidgetHost.tsx +++ b/frontend/src/shell/WidgetHost.tsx @@ -6,10 +6,25 @@ import type { UiResponse } from "../api/types"; export function WidgetHost({ sessionId }: { sessionId: string | null }) { const pending = useSessionStore((s) => s.pendingWidget); const clearPending = useSessionStore((s) => s.clearPending); + const pushToast = useSessionStore((s) => s.pushToast); if (!pending) return null; const Renderer = resolve(pending.widget); const onRespond = async (r: UiResponse) => { - if (sessionId) await postResponse(sessionId, r); + if (sessionId) { + try { + await postResponse(sessionId, r); + } catch (err) { + // Surface feedback and keep the widget pending so the user can retry. + pushToast({ + level: "error", + text: + err instanceof Error + ? `Invio risposta fallito: ${err.message}` + : "Invio risposta fallito.", + }); + return; + } + } clearPending(); }; return ; diff --git a/frontend/src/shell/WorkflowBar.test.tsx b/frontend/src/shell/WorkflowBar.test.tsx new file mode 100644 index 00000000..f32c0311 --- /dev/null +++ b/frontend/src/shell/WorkflowBar.test.tsx @@ -0,0 +1,22 @@ +// frontend/src/shell/WorkflowBar.test.tsx +import { render, screen } from "@testing-library/react"; +import { WorkflowBar } from "./WorkflowBar"; +import { useSessionStore } from "../store/sessionStore"; + +beforeEach(() => useSessionStore.getState().resetSession()); + +test("highlights the phase matching currentPhase", () => { + useSessionStore.getState().setPhase("F4"); + render(); + expect(screen.getByTestId("phase-F4")).toHaveAttribute("data-active", "true"); + // Other phases are not highlighted. + expect(screen.getByTestId("phase-F1")).toHaveAttribute("data-active", "false"); + expect(screen.getByTestId("phase-F8")).toHaveAttribute("data-active", "false"); +}); + +test("no phase highlighted when currentPhase is null", () => { + render(); + for (const id of ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8"]) { + expect(screen.getByTestId(`phase-${id}`)).toHaveAttribute("data-active", "false"); + } +}); diff --git a/frontend/src/store/sessionStore.test.ts b/frontend/src/store/sessionStore.test.ts index 00d43a9c..1a531a2c 100644 --- a/frontend/src/store/sessionStore.test.ts +++ b/frontend/src/store/sessionStore.test.ts @@ -24,3 +24,21 @@ test("clearPending removes the widget", () => { useSessionStore.getState().clearPending(); expect(useSessionStore.getState().pendingWidget).toBeNull(); }); + +test("ui_request sets currentPhase normalized to the short id", () => { + useSessionStore + .getState() + .applyEvent({ type: "ui_request", ui_request: { id: "u", widget: "select", phase: "F4_schema_linking" } }); + expect(useSessionStore.getState().currentPhase).toBe("F4"); +}); + +test("ui_request without a phase keeps the existing currentPhase", () => { + useSessionStore.getState().setPhase("F2"); + useSessionStore.getState().applyEvent({ type: "ui_request", ui_request: { id: "u", widget: "select" } }); + expect(useSessionStore.getState().currentPhase).toBe("F2"); +}); + +test("pushToast appends an error toast", () => { + useSessionStore.getState().pushToast({ level: "error", text: "boom" }); + expect(useSessionStore.getState().toasts.at(-1)).toEqual({ level: "error", text: "boom" }); +}); diff --git a/frontend/src/store/sessionStore.ts b/frontend/src/store/sessionStore.ts index 51223ced..2a916ac4 100644 --- a/frontend/src/store/sessionStore.ts +++ b/frontend/src/store/sessionStore.ts @@ -16,6 +16,7 @@ interface SessionState { clearPending: () => void; resetSession: () => void; setPhase: (phase: string | null) => void; + pushToast: (toast: { level: string; text: string }) => void; } const empty = { @@ -30,7 +31,13 @@ export const useSessionStore = create((set) => ({ ...empty, applyEvent: (e) => set((st) => { - if (e.type === "ui_request") return { pendingWidget: e.ui_request }; + if (e.type === "ui_request") + return { + pendingWidget: e.ui_request, + currentPhase: e.ui_request.phase + ? e.ui_request.phase.split("_")[0] + : st.currentPhase, + }; if (e.type === "text_delta") { const t = [...st.transcript]; const last = t.at(-1); @@ -45,4 +52,5 @@ export const useSessionStore = create((set) => ({ clearPending: () => set({ pendingWidget: null }), resetSession: () => set({ ...empty }), setPhase: (phase) => set({ currentPhase: phase }), + pushToast: (toast) => set((st) => ({ toasts: [...st.toasts, toast] })), }));