Restructure the workshop flow into the designer's cockpit: a persistent shell (header + 5-step stepper + sticky instrument rail) wrapping the phase routes via a React-Router layout route, so the rail stays mounted across navigation. - Design system: IBM Plex Mono + Newsreader; the full cockpit token set (light + dark) in index.css; a working light/dark theme toggle (store `theme` + useApplyTheme); a `switch` ui primitive. - Shell: CockpitLayout, Stepper (forward-gated), PanelChrome helpers. Every phase page restyled to the editorial panels + the WORKSHOP-FLOW fixes (channels-after-bind, domain framing + L1 prefill, L2/L3 prefill at 3/3, in-place submission finale). Store gains `tried` + `channels.saidHi` (v4). - Live rail (CockpitRail): real node heartbeat + agent activity log + ADD progress; sim telemetry (useTelemetry) for the waveform/accel/I2C behind a seam, marked SIM. - LED-matrix PIXEL MIRROR (real): the rail shows exactly what the physical matrix displays — API GET /nodes/:team/matrix reads the board's framebuffer off the :9999 relay (readMatrixFrame + the `matrixget` relay command); useMatrixMirror polls it and unpacks the 104 bits. Co-Authored-By: Claude Opus 4.8 <[email protected]>
174 lines
6.0 KiB
TypeScript
174 lines
6.0 KiB
TypeScript
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<PhaseKey, boolean>
|
||
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<Team>) => void
|
||
setTheme: (theme: Theme) => void
|
||
setTried: (tried: number) => void
|
||
setDevice: (patch: Partial<Device>) => void
|
||
setDomain: (d: string) => void
|
||
setChannels: (patch: Partial<Channels>) => void
|
||
completePhase: (phase: PhaseKey) => void
|
||
recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void
|
||
setAddLayer: <K extends keyof AddLayers>(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<PhaseKey, boolean>
|
||
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<PhaseKey, boolean>,
|
||
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<SessionState>()(
|
||
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
|
||
},
|
||
},
|
||
),
|
||
)
|