From ebd39886344b4d43f5c4748910c5cd750a67f4ed Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 28 Jun 2026 12:17:16 +0200 Subject: [PATCH] feat(frontend): Zustand session store + applyEvent --- frontend/src/store/sessionStore.test.ts | 26 +++++++++++++++ frontend/src/store/sessionStore.ts | 44 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 frontend/src/store/sessionStore.test.ts create mode 100644 frontend/src/store/sessionStore.ts diff --git a/frontend/src/store/sessionStore.test.ts b/frontend/src/store/sessionStore.test.ts new file mode 100644 index 00000000..00d43a9c --- /dev/null +++ b/frontend/src/store/sessionStore.test.ts @@ -0,0 +1,26 @@ +import { useSessionStore } from "./sessionStore"; + +beforeEach(() => useSessionStore.getState().resetSession()); + +test("ui_request sets pendingWidget", () => { + useSessionStore.getState().applyEvent({ type: "ui_request", ui_request: { id: "u1", widget: "select" } }); + expect(useSessionStore.getState().pendingWidget?.id).toBe("u1"); +}); + +test("text_delta accumulates into transcript", () => { + const s = useSessionStore.getState(); + s.applyEvent({ type: "text_delta", text: "Ana" }); + s.applyEvent({ type: "text_delta", text: "lisi" }); + expect(useSessionStore.getState().transcript.at(-1)?.text).toBe("Analisi"); +}); + +test("info pushes a toast", () => { + useSessionStore.getState().applyEvent({ type: "info", level: "warning", text: "attenzione" }); + expect(useSessionStore.getState().toasts.at(-1)).toEqual({ level: "warning", text: "attenzione" }); +}); + +test("clearPending removes the widget", () => { + useSessionStore.getState().applyEvent({ type: "ui_request", ui_request: { id: "u1", widget: "select" } }); + useSessionStore.getState().clearPending(); + expect(useSessionStore.getState().pendingWidget).toBeNull(); +}); diff --git a/frontend/src/store/sessionStore.ts b/frontend/src/store/sessionStore.ts new file mode 100644 index 00000000..e80b330e --- /dev/null +++ b/frontend/src/store/sessionStore.ts @@ -0,0 +1,44 @@ +import { create } from "zustand"; +import type { StreamEvent, WidgetDescriptor } from "../api/types"; + +interface Entry { + role: "assistant"; + text: string; +} + +interface SessionState { + pendingWidget: WidgetDescriptor | null; + transcript: Entry[]; + toasts: { level: string; text: string }[]; + lastSystemEvent: StreamEvent | null; + applyEvent: (e: StreamEvent) => void; + clearPending: () => void; + resetSession: () => void; +} + +const empty = { + pendingWidget: null, + transcript: [] as Entry[], + toasts: [] as { level: string; text: string }[], + lastSystemEvent: null, +}; + +export const useSessionStore = create((set) => ({ + ...empty, + applyEvent: (e) => + set((st) => { + if (e.type === "ui_request") return { pendingWidget: e.ui_request }; + if (e.type === "text_delta") { + const t = [...st.transcript]; + const last = t.at(-1); + if (last && !st.pendingWidget) t[t.length - 1] = { role: "assistant", text: last.text + e.text }; + else t.push({ role: "assistant", text: e.text }); + return { transcript: t }; + } + if (e.type === "info") return { toasts: [...st.toasts, { level: e.level ?? "info", text: e.text }] }; + if (e.type === "system_event") return { lastSystemEvent: e }; + return {}; + }), + clearPending: () => set({ pendingWidget: null }), + resetSession: () => set({ ...empty }), +}));