feat(workshop): reworked phase flow — agent makeup, sidebar advance, responsive

Rebuild the participant flow around the agent itself:
- Phases: Team registration (+ agent name) -> Meet your agent -> Skills &
  Policies (edit the agent's makeup) -> UnoQ Dashboard (React Flow
  configurator) -> Submit. Swapped/renamed the two module pages
  (Module1/2 -> ModuleDashboard/ModuleMakeup).
- Agent name captured at registration, shown on the dashboard and used in
  the chat greeting (live, not hard-coded), pushed to the board.
- One shared conversation across pages (AgentChatContext); Clawd the crab
  animates the LED matrix and reacts to chat status.
- MAKEUP slide-outs: view, edit (wrench), or AI-refine (wand) the agent's
  personality files and save them back to the board.
- Advance button moved into the sidebar for every phase (ProceedContext).
- Dropped the ADD-doc build + submission gate; Submit just records the entry.
- Responsive pass: sidebar auto-collapses below lg, fluid padding, React
  Flow pan/zoom/fit + Controls, architecture panel becomes a mobile overlay.
- Add @xyflow/react for the configurator.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-27 10:55:34 +02:00
co-authored by Claude Opus 4.8
parent 0797923933
commit 05207ba986
30 changed files with 1690 additions and 687 deletions
+219 -40
View File
@@ -1,58 +1,237 @@
import { createContext, useContext, useCallback, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { DomainPicker } from '@/components/DomainPicker'
import { AddLayerForm } from '@/components/AddLayerForm'
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
import { useNodeFeed } from '@/lib/useNodeFeed'
import {
ReactFlow,
ReactFlowProvider,
Background,
Controls,
Handle,
Position,
addEdge,
useEdgesState,
useNodesState,
type Edge,
type Connection,
type NodeTypes,
} from '@xyflow/react'
import { PanelHeading } from '@/components/cockpit/PanelChrome'
import { AgentChatPane, StarterRow } from '@/components/cockpit/AgentChatPieces'
import { WaveformCanvas } from '@/components/cockpit/WaveformCanvas'
import { useSetProceed } from '@/lib/ProceedContext'
import { useSharedAgentChat } from '@/lib/AgentChatContext'
import { useSession } from '@/store/session'
import { useNodeFeed } from '@/lib/useNodeFeed'
import { useTelemetry } from '@/lib/useTelemetry'
import { useMatrixMirror } from '@/lib/useMatrixMirror'
import { cn } from '@/lib/utils'
export function Module1() {
const navigate = useNavigate()
/**
* Module 1 · UnoQ Dashboard — a configurator built on React Flow. The chat card
* and the UnoQ dashboard are two nodes; the participant plumbs them together by
* dragging an edge from the chat's right handle to the dashboard's left handle.
* Until they're wired, no data flows through the dashboard (matrix off, no accel,
* no logs). Once connected the edge animates and the board goes live — then they
* drive the LED matrix + sensors through the agent and watch the data arrive.
* Wiring it up unlocks Proceed. (React Flow: https://reactflow.dev/learn)
*/
// Whether data is flowing (the edge is connected AND the board is online).
const LiveContext = createContext(false)
// ── the UnoQ dashboard node (right) ──────────────────────────────────────────
function UnoQNode() {
const live = useContext(LiveContext)
const teamId = useSession((s) => s.teamId)
const feed = useNodeFeed(teamId, true)
const l1 = useSession((s) => s.add.L1)
const domain = useSession((s) => s.domain)
const team = useSession((s) => s.team)
const tel = useTelemetry(live)
const mirror = useMatrixMirror(teamId, live)
const { logs } = useSharedAgentChat()
const dots = mirror ?? tel.matrix
const nodeName = team.name ? team.name.toLowerCase().replace(/\s+/g, '-') : 'crimson-node'
const recent = logs.slice(-6)
return (
<div className="w-[340px] rounded-[16px] bg-rail-bg p-[18px] text-rail-text shadow-[0_20px_50px_-24px_rgba(0,0,0,0.55)]">
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-rail-blue !bg-rail-bg" />
<div className="flex items-center gap-2.5">
<span className={cn('h-2.5 w-2.5 rounded-full', live ? 'bg-rail-green shadow-[0_0_10px_#3fd28a] animate-pulse' : 'bg-rail-dim2')} />
<span className="font-mono text-sm font-semibold text-rail-text3">{nodeName}</span>
<span className={cn('ml-auto rounded border px-[7px] py-0.5 font-mono text-[9px] tracking-[0.14em]', live ? 'border-[#2c6b4f] text-rail-green' : 'border-rail-line text-rail-dim2')}>
{live ? 'DATA FLOWING' : 'NO DATA'}
</span>
</div>
{/* LED matrix */}
<div className="mt-4 font-mono text-[9.5px] tracking-[0.18em] text-rail-dim">LED MATRIX · 13×8</div>
<div className="mt-2 flex justify-center rounded-[10px] border border-rail-line bg-rail-inset px-3 py-2.5">
<div className="grid gap-1" style={{ gridTemplateColumns: 'repeat(13, 1fr)' }}>
{Array.from({ length: 104 }).map((_, i) => {
const lit = live && dots[i]
return (
<span
key={i}
className="h-[9px] w-[9px] rounded-[2px] transition-[background] duration-75"
style={{ background: lit ? 'oklch(0.7 0.2 34)' : 'oklch(0.28 0.01 260)', boxShadow: lit ? '0 0 5px oklch(0.7 0.2 34)' : 'none' }}
/>
)
})}
</div>
</div>
{/* live acceleration */}
<div className="mt-4 flex items-center justify-between">
<span className="font-mono text-[9.5px] tracking-[0.18em] text-rail-dim">LIVE ACCELERATION · g</span>
<span className={cn('font-mono text-[9px] tracking-[0.1em]', !live ? 'text-rail-dim' : tel.event === 'impact' ? 'text-rail-spike' : 'text-rail-green')}>
{!live ? 'NO STREAM' : tel.event === 'impact' ? 'IMPACT SPIKE' : 'NOMINAL'}
</span>
</div>
<div className="mt-2">
{live ? (
<WaveformCanvas wave={tel.wave} impact={tel.event === 'impact'} />
) : (
<div className="flex h-[100px] items-center justify-center rounded-[10px] border border-rail-line bg-rail-inset font-mono text-[10px] text-rail-dim3">
— no data flowing —
</div>
)}
</div>
{/* logs */}
<div className="mt-4 font-mono text-[9.5px] tracking-[0.18em] text-rail-dim">AGENT LOGS</div>
<div className="mt-2 h-[92px] overflow-hidden rounded-[10px] border border-rail-line bg-rail-inset px-3 py-2 font-mono text-[10.5px] leading-[1.6]">
{!live ? (
<div className="text-rail-dim2">— no data flowing —</div>
) : recent.length === 0 ? (
<div className="text-rail-dim2">idle — ask your agent to do something</div>
) : (
recent.map((e, i) => (
<div key={i} className="truncate text-rail-text2">
{e.label}
</div>
))
)}
</div>
</div>
)
}
// ── the chat node (left) ─────────────────────────────────────────────────────
function ChatNode() {
const connected = useSession((s) => s.device.connected)
const feed = useNodeFeed(useSession((s) => s.teamId), connected)
const online = connected && feed.online
const { messages, starters, sending, send } = useSharedAgentChat()
return (
<div className="w-[360px] rounded-[16px] border border-line bg-surface-soft p-[18px] shadow-[0_20px_50px_-24px_rgba(0,0,0,0.35)]">
<AgentChatPane messages={messages} sending={sending} online={online} onSend={(t) => send(t)} heightClass="h-[200px]" />
<div className="mt-3">
<StarterRow starters={starters} sending={sending} online={online} onSend={send} />
</div>
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-blue !bg-surface" />
</div>
)
}
const NODE_TYPES: NodeTypes = { chat: ChatNode, unoq: UnoQNode }
const INITIAL_NODES = [
// chat is pinned on the left (static); the dashboard floats free (draggable)
{ id: 'chat', type: 'chat', position: { x: 24, y: 24 }, draggable: false, data: {} },
{ id: 'unoq', type: 'unoq', position: { x: 560, y: 24 }, data: {} },
]
function Configurator({ onPlumbedChange }: { onPlumbedChange: (v: boolean) => void }) {
const [nodes, , onNodesChange] = useNodesState(INITIAL_NODES)
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
const onConnect = useCallback(
(c: Connection) => {
setEdges((es) => {
const next = addEdge({ ...c, animated: true }, es)
onPlumbedChange(next.length > 0)
return next
})
},
[setEdges, onPlumbedChange],
)
const handleEdgesChange = useCallback(
(changes: Parameters<typeof onEdgesChange>[0]) => {
onEdgesChange(changes)
// recompute after a delete
setEdges((es) => {
onPlumbedChange(es.length > 0)
return es
})
},
[onEdgesChange, setEdges, onPlumbedChange],
)
return (
<div className="h-[calc(100vh-240px)] min-h-[440px] w-full overflow-hidden rounded-[18px] border border-line bg-[var(--surface)]">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
onNodesChange={onNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
zoomOnScroll={false}
zoomOnDoubleClick={false}
minZoom={0.4}
maxZoom={1.5}
fitView
fitViewOptions={{ padding: 0.18 }}
>
<Background gap={22} size={1.5} />
<Controls showInteractive={false} />
</ReactFlow>
</div>
)
}
export function ModuleDashboard() {
const navigate = useNavigate()
const connected = useSession((s) => s.device.connected)
const teamId = useSession((s) => s.teamId)
const feed = useNodeFeed(teamId, connected)
const online = connected && feed.online
const completePhase = useSession((s) => s.completePhase)
const sensed = feed.online || feed.activity.length > 0
const ready = sensed && domain.trim().length > 0 && l1.trim().length > 0
// wiring state, lifted out of the flow so Proceed + the status line can read it
const [wired, setWired] = useState(false)
const live = wired && online
const ready = wired
const onProceed = () => {
completePhase('m1')
navigate('/workshop/module2')
completePhase('m2')
navigate('/workshop/add')
}
// the advance button lives in the sidebar
useSetProceed({ label: 'Proceed to Module 3 →', disabled: !ready, onClick: onProceed })
return (
<section>
<PanelHeading
title="Module 1 · Domain & events"
intro="Name the domain your agent is for and the events it must sense and act on — this is Layer 1 of your Agent Design Document."
size={46}
/>
<PanelHeading title="UnoQ Dashboard" size={34} />
{/* Pick your domain — the seed of all five layers */}
<PanelCard emphasized className="mt-9">
<div className="text-[17px] font-semibold">Pick your domain</div>
<p className="mt-1.5 text-[14px] leading-[1.5] text-ink-3">
This one line seeds all five layers — the domain your agent serves and the events it must notice.
</p>
<div className="mt-4">
<DomainPicker />
</div>
</PanelCard>
<p className="mt-3 max-w-[620px] text-[15px] leading-[1.6] text-ink-2">
Drag the chat&rsquo;s right handle onto the dashboard&rsquo;s left edge to wire them — the dashboard
floats free, so drag it wherever you like.
</p>
<div className="mt-5">
<AddLayerForm
layer="L1"
title="ADD · Layer 1 — Domain & events"
description="What domain does your node operate in, and what events must it notice?"
placeholder="Domain: structural resonance monitoring. Events: an impact spike, a sustained sway, a stale sensor."
/>
</div>
<LiveContext.Provider value={live}>
<ReactFlowProvider>
<div className="mt-6">
<Configurator onPlumbedChange={setWired} />
</div>
</ReactFlowProvider>
</LiveContext.Provider>
<div className="mt-6 flex justify-end">
<ProceedButton disabled={!ready} onClick={onProceed}>
Proceed to Module 2 →
</ProceedButton>
<div className="mt-4">
<span className={cn('font-mono text-[12px] tracking-[0.04em]', wired ? 'text-green' : 'text-ink-3')}>
{wired ? (live ? '● wired · data flowing' : '● wired · board offline') : '○ not wired — drag the chat to the dashboard'}
</span>
</div>
</section>
)