Files
apress/src/pages/ModuleDashboard.tsx
T
Omar SobhandClaude Opus 4.8 05207ba986 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]>
2026-07-27 10:55:34 +02:00

239 lines
9.5 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 { createContext, useContext, useCallback, useState } from 'react'
import { useNavigate } from 'react-router-dom'
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'
/**
* 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 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)
// 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('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="UnoQ Dashboard" size={34} />
<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>
<LiveContext.Provider value={live}>
<ReactFlowProvider>
<div className="mt-6">
<Configurator onPlumbedChange={setWired} />
</div>
</ReactFlowProvider>
</LiveContext.Provider>
<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>
)
}