Files
apress/src/store/session.ts
T
Omar SobhandClaude Opus 4.8 1a39457940 feat(web): two-pane "cockpit" redesign with a live board rail
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]>
2026-07-23 07:38:14 -07:00

174 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
},
},
),
)