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]>
239 lines
9.5 KiB
TypeScript
239 lines
9.5 KiB
TypeScript
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’s right handle onto the dashboard’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>
|
||
)
|
||
}
|