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:
co-authored by
Claude Opus 4.8
parent
0797923933
commit
05207ba986
+219
-40
@@ -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’s right handle onto the dashboard’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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user