import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' export type PhaseKey = 'reg' | 'setup' | 'm1' | 'm2' | 'add' export const PHASE_ORDER: PhaseKey[] = ['reg', 'setup', 'm1', 'm2', 'add'] export interface Team { name: string members: string[] kit: string } export interface Device { connected: boolean port: string | null uptimeS: number /** The board's LAN URL for its embedded ZeroClaw web UI, so the team can open * their own node. Null until a live claim provides it. */ nodeUrl: string | null } export interface SessionStats { calls: number nominal: number anomalous: number critical: number } /** * The five layers of the Agent Design Document. All free-text. * L1 = Domain & events * L2 = Skills * L3 = Policies * L4 = Harness * L5 = Loops */ export interface AddLayers { L1: string L2: string L3: string L4: string L5: string } export interface Submission { code: string | null submittedAt: string | null } /** Optional extra ways to reach the node, configured during onboarding. */ export interface Channels { /** Telegram bot token (from @BotFather); null until the wizard completes. */ telegram: string | null /** Whether the team enabled browser voice on the node. */ voice: boolean /** Whether the team has completed the "say hi" handshake with their agent. */ saidHi: boolean } export type Theme = 'light' | 'dark' /** Stable per-browser identity, generated once and persisted. */ function genTeamId(): string { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID() return `team-${Math.abs(Date.now() ^ (Math.floor(Math.random() * 1e9))).toString(36)}` } export interface SessionState { teamId: string team: Team device: Device /** The team's free-text problem domain, e.g. "image measurement". */ domain: string phases: Record stats: SessionStats add: AddLayers submission: Submission /** Extra channels + voice, set up during onboarding (client-side prefs). */ channels: Channels /** UI theme, toggled from the cockpit header; persisted. */ theme: Theme /** How many of Module 2's canned prompts have been run successfully (0–3). */ tried: number setTeam: (patch: Partial) => void setTheme: (theme: Theme) => void setTried: (tried: number) => void setDevice: (patch: Partial) => void setDomain: (d: string) => void setChannels: (patch: Partial) => void completePhase: (phase: PhaseKey) => void recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void setAddLayer: (key: K, value: AddLayers[K]) => void setSubmission: (s: Submission) => void /** Drop the board binding (keep the team's work) so they can re-bind. */ disconnect: () => void /** Adopt a canonical team on a resume (lost-browser re-claim): switch identity * and restore name/members/phases/stats so we don't clobber synced progress. */ resumeTeam: (snap: { id: string name: string kit: string members: string[] phases: Record stats: SessionStats }) => void reset: () => void } const initial = { teamId: genTeamId(), team: { name: '', members: [] as string[], kit: 'KIT-01' }, device: { connected: false, port: null, uptimeS: 0, nodeUrl: null }, domain: '', phases: { reg: false, setup: false, m1: false, m2: false, add: false } as Record, stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 }, add: { L1: '', L2: '', L3: '', L4: '', L5: '' } as AddLayers, submission: { code: null, submittedAt: null } as Submission, channels: { telegram: null, voice: false, saidHi: false } as Channels, theme: 'light' as Theme, tried: 0, } export const useSession = create()( persist( (set) => ({ ...initial, setTeam: (patch) => set((s) => ({ team: { ...s.team, ...patch } })), setTheme: (theme) => set({ theme }), setTried: (tried) => set({ tried }), setDevice: (patch) => set((s) => ({ device: { ...s.device, ...patch } })), setDomain: (domain) => set({ domain }), setChannels: (patch) => set((s) => ({ channels: { ...s.channels, ...patch } })), completePhase: (phase) => set((s) => ({ phases: { ...s.phases, [phase]: true } })), recordEvent: (kind) => set((s) => ({ stats: { ...s.stats, calls: s.stats.calls + 1, [kind]: s.stats[kind] + 1 }, })), setAddLayer: (key, value) => set((s) => ({ add: { ...s.add, [key]: value } })), setSubmission: (submission) => set({ submission }), // Explicit "disconnect": drop the board binding but KEEP the team's work // (name, members, domain, phases, ADD). They can re-bind and resume. disconnect: () => set({ device: { connected: false, port: null, uptimeS: 0, nodeUrl: null } }), resumeTeam: (snap) => set((s) => ({ teamId: snap.id, team: { name: snap.name, kit: snap.kit, members: snap.members }, phases: { ...s.phases, ...snap.phases }, stats: snap.stats, device: { ...s.device, connected: true }, })), reset: () => set(initial), }), { name: 'apess_state', // localStorage (not sessionStorage): the workshop runs on the team's own // laptop, so their board binding + progress must survive a page refresh, // a hard reload, navigating away, and even closing/reopening the tab — // until they explicitly hit Disconnect (or Reset). sessionStorage was // tab-volatile and dropped the connection on a hard reload. storage: createJSONStorage(() => localStorage), version: 4, // v1 held a different shape (add.L1 was an object, no `domain`) — too stale // to salvage, so reset. From v2 on we merge over `initial` so newly-added // fields (e.g. `channels`) are always present without wiping progress. migrate: (_persisted, version) => { if (version < 2) return { ...initial } return { ...initial, ...(_persisted as object) } as SessionState }, }, ), )