feat(cockpit): Clawd mascot + live agent chat on Meet your agent

Rework the Meet-your-agent rail into a self-contained agent surface:

- Clawd, the Claude Code pixel crab, animates the LED matrix on a 26x16
  grid, coupled to the chat lifecycle (idle stare / claws pump while
  working / green flash on reply). Static antennas, permanent black eyes.
- A full free-form chat to the default agent (useAgentChat): free text
  goes to the blocking /webhook path so conversational replies actually
  render; the 3 canned prompts stay fire-and-forget + SSE (tool turns).
- Chat and the working log are separate panes; the 3 prompts became
  starter buttons; the ZeroClaw runtime + channels moved into an
  Advanced drawer. The whole agent rail is now theme-aware (light/dark).
- Strip the phase eyebrows from the sidebar + page headers; reduce the
  EnvSetup left column to editorial copy.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-23 16:35:17 -07:00
co-authored by Claude Opus 4.8
parent 5975287d56
commit a9a5176f7c
13 changed files with 685 additions and 148 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ export function CockpitLayout() {
</Suspense> </Suspense>
</aside> </aside>
) : ( ) : (
<CockpitRail /> <CockpitRail variant={pathname === '/workshop/setup' ? 'agent' : 'full'} />
)} )}
</div> </div>
</div> </div>
+131 -26
View File
@@ -1,9 +1,13 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { useNodeFeed } from '@/lib/useNodeFeed' import { useNodeFeed } from '@/lib/useNodeFeed'
import { useTelemetry } from '@/lib/useTelemetry' import { useTelemetry } from '@/lib/useTelemetry'
import { useMatrixMirror } from '@/lib/useMatrixMirror' import { useMatrixMirror } from '@/lib/useMatrixMirror'
import { useClawd, GRID_W } from '@/lib/clawSprite'
import { useAgentChat } from '@/lib/useAgentChat'
import { WaveformCanvas } from './WaveformCanvas' import { WaveformCanvas } from './WaveformCanvas'
import { RailAgent } from './RailAgent'
import type { NodeActivityKind } from '@/types' import type { NodeActivityKind } from '@/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ADD_LAYERS } from '@/lib/addLayers' import { ADD_LAYERS } from '@/lib/addLayers'
@@ -33,31 +37,46 @@ const NEXT_BY_PATH: Record<string, string[]> = {
'/workshop/add': ['L4', 'L5'], '/workshop/add': ['L4', 'L5'],
} }
function RailSection({ label, children }: { label: string; children: React.ReactNode }) { function RailSection({ label, light, children }: { label: string; light?: boolean; children: React.ReactNode }) {
return ( return (
<div className="mt-5"> <div className="mt-5">
<div className="font-mono text-[9.5px] tracking-[0.18em] text-rail-dim">{label}</div> <div className={cn('font-mono text-[9.5px] tracking-[0.18em]', light ? 'text-ink-3' : 'text-rail-dim')}>{label}</div>
<div className="mt-2">{children}</div> <div className="mt-2">{children}</div>
</div> </div>
) )
} }
export function CockpitRail() { export function CockpitRail({ variant = 'full' }: { variant?: 'full' | 'agent' } = {}) {
const teamId = useSession((s) => s.teamId) const teamId = useSession((s) => s.teamId)
const team = useSession((s) => s.team) const team = useSession((s) => s.team)
const connected = useSession((s) => s.device.connected) const connected = useSession((s) => s.device.connected)
const add = useSession((s) => s.add) const add = useSession((s) => s.add)
const submitted = useSession((s) => s.submission.code != null) const submitted = useSession((s) => s.submission.code != null)
const setTried = useSession((s) => s.setTried)
const { pathname } = useLocation() const { pathname } = useLocation()
const feed = useNodeFeed(teamId, connected) const feed = useNodeFeed(teamId, connected)
const tel = useTelemetry(connected) const tel = useTelemetry(connected)
const online = connected && feed.online const online = connected && feed.online
// On the "agent" rail (Meet your agent) the matrix shows Clawd, the crab —
// not a board mirror — driven by the live chat status, so we skip the mirror
// poll and animate the sprite instead.
const agentView = variant === 'agent'
const chat = useAgentChat(teamId, agentView && online)
const claw = useClawd(chat.status)
// Folding the canned starters into the rail means their completions now drive
// the Module 2 prefill (`tried`); bump it as starters finish (never lower it).
useEffect(() => {
if (agentView && chat.doneCount > 0) setTried(chat.doneCount)
}, [agentView, chat.doneCount, setTried])
// Pixel-perfect mirror of the physical matrix (real board frame). Falls back // Pixel-perfect mirror of the physical matrix (real board frame). Falls back
// to the sim frame until the first real frame arrives. // to the sim frame until the first real frame arrives.
const mirror = useMatrixMirror(teamId, online) const mirror = useMatrixMirror(teamId, online && !agentView)
const matrixDots = mirror ?? tel.matrix const matrixDots = agentView ? claw.dots : mirror ?? tel.matrix
const matrixLive = mirror != null const matrixLive = mirror != null
const matrixCols = agentView ? GRID_W : 13
// Clawd flashes green on a reply; the board mirror stays ASCII-orange.
const litColor = agentView && claw.color === 'green' ? 'oklch(0.82 0.17 152)' : 'oklch(0.7 0.2 34)'
const nodeName = team.name ? team.name.toLowerCase().replace(/\s+/g, '-') : 'crimson-node' const nodeName = team.name ? team.name.toLowerCase().replace(/\s+/g, '-') : 'crimson-node'
const nextSet = new Set(NEXT_BY_PATH[pathname] ?? []) const nextSet = new Set(NEXT_BY_PATH[pathname] ?? [])
@@ -68,47 +87,95 @@ export function CockpitRail() {
const log = feed.activity.slice(0, 6) const log = feed.activity.slice(0, 6)
// The agent rail is theme-aware (light in light mode); the instrument rail
// stays a fixed-dark device screen. The LED matrix itself is a screen either way.
const label = agentView ? 'text-ink-3' : 'text-rail-dim'
return ( return (
<aside className="sticky top-6 rounded-[18px] bg-rail-bg p-[22px] text-rail-text shadow-[0_20px_50px_-24px_rgba(0,0,0,0.5)]"> <aside
className={cn(
'sticky top-6 rounded-[18px] p-[22px] shadow-[0_20px_50px_-24px_rgba(0,0,0,0.5)]',
agentView ? 'border border-line bg-surface-soft text-ink' : 'bg-rail-bg text-rail-text',
)}
>
{/* 1 · node header / heartbeat */} {/* 1 · node header / heartbeat */}
<div className="flex items-center gap-[11px]"> <div className="flex items-center gap-[11px]">
<span <span
className={cn( className={cn(
'h-2.5 w-2.5 rounded-full', 'h-2.5 w-2.5 rounded-full',
online ? 'bg-rail-green shadow-[0_0_10px_#3fd28a] animate-pulse' : 'bg-rail-dim2', online
? agentView
? 'bg-green shadow-[0_0_10px_#3fd28a] animate-pulse'
: 'bg-rail-green shadow-[0_0_10px_#3fd28a] animate-pulse'
: agentView
? 'bg-ink-3'
: 'bg-rail-dim2',
)} )}
/> />
<span className="font-mono text-sm font-semibold tracking-[0.02em] text-rail-text3">{nodeName}</span> <span
className={cn(
'font-mono text-sm font-semibold tracking-[0.02em]',
agentView ? 'text-ink' : 'text-rail-text3',
)}
>
{nodeName}
</span>
<span <span
className={cn( className={cn(
'font-mono text-[9px] tracking-[0.14em] rounded border px-[7px] py-0.5', 'font-mono text-[9px] tracking-[0.14em] rounded border px-[7px] py-0.5',
online ? 'text-rail-green border-[#2c6b4f]' : 'text-rail-dim2 border-rail-line', online
? agentView
? 'text-green border-green/50'
: 'text-rail-green border-[#2c6b4f]'
: agentView
? 'text-ink-3 border-line'
: 'text-rail-dim2 border-rail-line',
)} )}
> >
{online ? 'LIVE' : 'OFFLINE'} {online ? 'LIVE' : 'OFFLINE'}
</span> </span>
<span className="ml-auto font-mono text-[10px] text-rail-dim">arduino uno q</span> <span className={cn('ml-auto font-mono text-[10px]', label)}>arduino uno q</span>
</div> </div>
{/* 2 · LED matrix 13×8 — real pixel mirror of the physical matrix */} {/* 2 · LED matrix — board mirror (full rail) or Clawd the crab (agent) */}
<div className="mt-5"> <div className="mt-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="font-mono text-[9.5px] tracking-[0.18em] text-rail-dim">LED MATRIX · 13×8</div> <div className={cn('font-mono text-[9.5px] tracking-[0.18em]', label)}>
{matrixLive && ( {agentView ? 'LED MATRIX · 26×16' : 'LED MATRIX · 13×8'}
<span className="font-mono text-[8.5px] tracking-[0.12em] text-rail-green">● MIRROR</span> </div>
{agentView ? (
<span
className="font-mono text-[8.5px] tracking-[0.12em]"
style={{ color: claw.color === 'green' ? '#3fd28a' : '#ff7a3c' }}
>
{claw.color === 'green' ? '● REPLIED' : chat.status === 'working' ? '● WORKING' : '● CLAWD'}
</span>
) : (
matrixLive && <span className="font-mono text-[8.5px] tracking-[0.12em] text-rail-green">● MIRROR</span>
)} )}
</div> </div>
<div className="mt-2 flex justify-center rounded-[10px] border border-rail-line bg-rail-inset px-[13px] py-3"> <div
<div className="grid gap-1" style={{ gridTemplateColumns: 'repeat(13, 1fr)' }}> className={cn(
{Array.from({ length: 104 }).map((_, i) => { 'mt-2 rounded-[10px] border border-rail-line bg-rail-inset',
const lit = tel.live && matrixDots[i] agentView ? 'p-3' : 'flex justify-center px-[13px] py-3',
)}
>
<div
className={cn('grid', agentView ? 'w-full gap-[2px]' : 'gap-1')}
style={{ gridTemplateColumns: `repeat(${matrixCols}, 1fr)` }}
>
{matrixDots.map((on, i) => {
const lit = agentView ? on : tel.live && on
return ( return (
<span <span
key={i} key={i}
className="h-[9px] w-[9px] rounded-[2px] transition-[background] duration-75" className={cn(
'rounded-[2px] transition-[background] duration-75',
agentView ? 'aspect-square w-full' : 'h-[9px] w-[9px]',
)}
style={{ style={{
background: lit ? 'oklch(0.7 0.2 34)' : 'oklch(0.28 0.01 260)', background: lit ? litColor : 'oklch(0.28 0.01 260)',
boxShadow: lit ? '0 0 5px oklch(0.7 0.2 34)' : 'none', boxShadow: lit ? `0 0 5px ${litColor}` : 'none',
}} }}
/> />
) )
@@ -117,6 +184,9 @@ export function CockpitRail() {
</div> </div>
</div> </div>
{/* 3 & 4 · sensor telemetry — omitted on the "agent" variant (Meet your agent) */}
{variant !== 'agent' && (
<>
{/* 3 · I2C bus (telemetry) */} {/* 3 · I2C bus (telemetry) */}
<RailSection label="I2C BUS · 100 kHz"> <RailSection label="I2C BUS · 100 kHz">
<div className="rounded-[10px] border border-rail-line2 bg-rail-panel px-1 py-1.5 font-mono text-xs"> <div className="rounded-[10px] border border-rail-line2 bg-rail-panel px-1 py-1.5 font-mono text-xs">
@@ -178,8 +248,20 @@ export function CockpitRail() {
))} ))}
</div> </div>
</RailSection> </RailSection>
</>
)}
{/* 5 · agent activity log (real) */} {/* 5 · agent — chat + logs as separate panes (agent view) or the read-only log */}
{agentView ? (
<RailAgent
messages={chat.messages}
logs={chat.logs}
sending={chat.sending}
online={online}
starters={chat.starters}
onSend={(t, id) => void chat.send(t, id)}
/>
) : (
<RailSection label="AGENT ACTIVITY"> <RailSection label="AGENT ACTIVITY">
<div className="h-[132px] overflow-hidden rounded-[10px] border border-rail-line bg-rail-inset px-[13px] py-[11px] font-mono text-[11px] leading-[1.75]"> <div className="h-[132px] overflow-hidden rounded-[10px] border border-rail-line bg-rail-inset px-[13px] py-[11px] font-mono text-[11px] leading-[1.75]">
{log.length === 0 ? ( {log.length === 0 ? (
@@ -193,20 +275,43 @@ export function CockpitRail() {
)} )}
</div> </div>
</RailSection> </RailSection>
)}
{/* 6 · ADD progress (real, local) */} {/* 6 · ADD progress (real, local) */}
<RailSection label="AGENT DESIGN DOC"> <RailSection label="AGENT DESIGN DOC" light={agentView}>
<div className="flex flex-col gap-px font-mono text-[11px]"> <div className="flex flex-col gap-px font-mono text-[11px]">
{ADD_LAYERS.map(({ key, n, title }) => { {ADD_LAYERS.map(({ key, n, title }) => {
const st = layerState(key) const st = layerState(key)
return ( return (
<div key={key} className="flex items-center gap-2.5 px-0.5 py-[7px]"> <div key={key} className="flex items-center gap-2.5 px-0.5 py-[7px]">
<span className={cn(st === 'idle' ? 'text-[#4a5060]' : 'text-rail-blue')}>L{n}</span> <span
<span className={cn(st === 'idle' ? 'text-rail-dim2' : 'text-rail-text2')}>{title}</span> className={cn(
st === 'idle' ? (agentView ? 'text-ink-3' : 'text-[#4a5060]') : agentView ? 'text-blue' : 'text-rail-blue',
)}
>
L{n}
</span>
<span
className={cn(
st === 'idle' ? (agentView ? 'text-faint' : 'text-rail-dim2') : agentView ? 'text-ink-2' : 'text-rail-text2',
)}
>
{title}
</span>
<span <span
className={cn( className={cn(
'ml-auto', 'ml-auto',
st === 'done' ? 'text-rail-green' : st === 'next' ? 'text-[#d9a441]' : 'text-rail-dim3', st === 'done'
? agentView
? 'text-green'
: 'text-rail-green'
: st === 'next'
? agentView
? 'text-amber'
: 'text-[#d9a441]'
: agentView
? 'text-ink-3'
: 'text-rail-dim3',
)} )}
> >
{st === 'done' ? 'done' : st === 'next' ? 'next' : '—'} {st === 'done' ? 'done' : st === 'next' ? 'next' : '—'}
+4 -8
View File
@@ -9,25 +9,21 @@ export function Eyebrow({ children }: { children: React.ReactNode }) {
) )
} }
/** Editorial panel header: eyebrow + big Newsreader H1 + intro paragraph. */ /** Editorial panel header: big Newsreader H1 + intro paragraph. */
export function PanelHeading({ export function PanelHeading({
eyebrow,
title, title,
intro, intro,
size = 52, size = 52,
}: { }: {
eyebrow: string /** Deprecated — the phase chip was removed; kept optional so callers don't break. */
eyebrow?: string
title: string title: string
intro?: string intro?: string
size?: number size?: number
}) { }) {
return ( return (
<div> <div>
<Eyebrow>{eyebrow}</Eyebrow> <h1 className="font-semibold leading-[1.03] tracking-[-0.02em] text-ink" style={{ fontSize: size }}>
<h1
className="mt-[22px] font-semibold leading-[1.03] tracking-[-0.02em] text-ink"
style={{ fontSize: size }}
>
{title} {title}
</h1> </h1>
{intro && <p className="mt-[18px] max-w-[580px] text-[18px] leading-[1.55] text-ink-2">{intro}</p>} {intro && <p className="mt-[18px] max-w-[580px] text-[18px] leading-[1.55] text-ink-2">{intro}</p>}
+232
View File
@@ -0,0 +1,232 @@
import { useEffect, useRef, useState } from 'react'
import type { NodeActivityKind } from '@/types'
import type { ChatMessage, LogItem, StarterState } from '@/lib/useAgentChat'
import { OpenYourNode } from '@/components/OpenYourNode'
import { TelegramSetup } from '@/components/TelegramSetup'
import { VoiceSetup } from '@/components/VoiceSetup'
import { cn } from '@/lib/utils'
/**
* The rail's agent surface, stacked as four deliberately separate pieces:
* 1. CHAT — a full back-and-forth with the default agent (+ a greeting)
* 2. STARTERS — the three canned prompts as buttons; a tap runs one in the chat
* 3. AGENT LOGS — a dropdown into the agent's raw working trace
* 4. ADVANCED — a dropdown for the ZeroClaw runtime + extra channels/voice
*
* Unlike the instrument rail (fixed dark), this panel is theme-aware — it uses
* the app's ink/line/surface tokens so it reads light in light mode and dark in
* dark mode, keeping every field legible.
*/
const LINE_COLOR: Record<NodeActivityKind, string> = {
thinking: 'text-ink-3',
tool: 'text-ink-2',
flash: 'text-blue',
error: 'text-destructive',
response: 'text-green',
fallback: 'text-amber',
}
/** The three canned prompts — imperative, so the on-board model reliably runs tools. */
const STARTERS: { id: string; label: string; text: string }[] = [
{ id: 'i2c', label: 'List I2C devices', text: 'List the I2C devices on the bus' },
{ id: 'count', label: 'Count on the matrix', text: 'Count to 100 and print the value once a second in the LED matrix' },
{ id: 'scroll', label: 'Scroll GO CLAWS', text: 'Scroll GO CLAWS on the LED matrix' },
]
export interface RailAgentProps {
messages: ChatMessage[]
logs: LogItem[]
sending: boolean
online: boolean
starters: Record<string, StarterState>
onSend: (text: string, starterId?: string) => void
}
/** A theme-aware collapsible drawer. */
function Drawer({
label,
meta,
testid,
children,
}: {
label: string
meta?: string
testid: string
children: React.ReactNode
}) {
const [open, setOpen] = useState(false)
return (
<div>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
data-testid={`${testid}-toggle`}
className="flex w-full items-center gap-2 rounded-[9px] border border-line bg-surface-soft px-3 py-2 text-left transition-colors hover:border-blue"
>
<span className={cn('font-mono text-[9px] text-ink-3 transition-transform duration-150', open && 'rotate-90')}>
▶
</span>
<span className="font-mono text-[9.5px] tracking-[0.18em] text-ink-3">{label}</span>
{meta && <span className="ml-auto font-mono text-[9px] text-faint">{meta}</span>}
</button>
{open && (
<div className="mt-2" data-testid={testid}>
{children}
</div>
)}
</div>
)
}
export function RailAgent({ messages, logs, sending, online, starters, onSend }: RailAgentProps) {
const [draft, setDraft] = useState('')
const chatScroll = useRef<HTMLDivElement>(null)
useEffect(() => {
if (chatScroll.current) chatScroll.current.scrollTop = chatScroll.current.scrollHeight
}, [messages])
const submit = () => {
const t = draft.trim()
if (!t || sending) return
onSend(t)
setDraft('')
}
return (
<div className="mt-5 space-y-3">
{/* ── 1 · Chat ── */}
<div>
<div className="font-mono text-[9.5px] tracking-[0.18em] text-ink-3">CHAT · DEFAULT AGENT</div>
<div className="mt-2 rounded-[10px] border border-line bg-surface">
<div
ref={chatScroll}
data-testid="rail-chat-transcript"
className="h-[230px] space-y-2 overflow-y-auto px-[13px] py-3 text-[12px] leading-[1.5]"
>
{messages.length === 0 ? (
<div className="font-mono text-[11px] text-ink-3">
{online ? 'say something to your agent — it runs on the board' : 'connect your board to chat'}
</div>
) : (
messages.map((m, i) =>
m.who === 'you' ? (
<div key={i} className="flex justify-end">
<span className="max-w-[85%] rounded-[9px] bg-blue/10 px-2.5 py-1.5 text-ink">{m.text}</span>
</div>
) : (
<div key={i} className="flex justify-start">
<span
className={cn(
'max-w-[88%] rounded-[9px] border border-line bg-surface-soft px-2.5 py-1.5',
m.kind === 'error' ? 'text-destructive' : 'text-ink-2',
)}
>
{m.text}
</span>
</div>
),
)
)}
</div>
{/* composer */}
<form
className="flex items-center gap-2 border-t border-line px-2.5 py-2"
onSubmit={(e) => {
e.preventDefault()
submit()
}}
>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={!online}
data-testid="rail-chat-input"
placeholder={online ? 'Message your agent…' : 'board offline'}
className="min-w-0 flex-1 bg-transparent font-mono text-[11.5px] text-ink placeholder:text-faint focus:outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={!online || sending || !draft.trim()}
className="shrink-0 rounded-[7px] border border-line bg-surface-soft px-2.5 py-1 font-mono text-[10px] tracking-[0.1em] text-blue transition-colors hover:border-blue disabled:opacity-40"
>
{sending ? '…' : 'SEND'}
</button>
</form>
</div>
</div>
{/* ── 2 · Starters (the canned prompts, folded out of the old chat card) ── */}
<div className="flex flex-col gap-1.5">
{STARTERS.map((s) => {
const st = starters[s.id] ?? 'idle'
return (
<button
key={s.id}
type="button"
data-testid={`starter-${s.id}`}
disabled={!online || (sending && st !== 'running')}
onClick={() => onSend(s.text, s.id)}
className={cn(
'flex items-center gap-2.5 rounded-[8px] border px-3 py-1.5 text-left font-mono text-[11px] transition-colors disabled:opacity-40',
st === 'done'
? 'border-green/50 bg-green/10 text-green'
: st === 'running'
? 'border-amber/50 text-amber'
: 'border-line bg-surface text-ink-2 hover:border-blue',
)}
>
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
st === 'done' ? 'bg-green' : st === 'running' ? 'bg-amber animate-pulse' : 'bg-ink-3',
)}
/>
<span className="flex-1">{s.label}</span>
<span className="text-[8.5px] tracking-[0.12em] text-ink-3">
{st === 'done' ? 'DONE ✓' : st === 'running' ? 'RUNNING…' : 'RUN →'}
</span>
</button>
)
})}
</div>
{/* ── 3 · Agent logs ── */}
<Drawer label="AGENT LOGS" meta={String(logs.length)} testid="rail-logs">
<div
className="h-[150px] overflow-y-auto rounded-[10px] border border-line bg-surface px-[13px] py-[11px] font-mono text-[11px] leading-[1.7]"
ref={(el) => {
if (el) el.scrollTop = el.scrollHeight
}}
>
{logs.length === 0 ? (
<div className="text-ink-3">idle — no agent activity yet</div>
) : (
logs.map((e, i) => (
<div key={i} className={cn('truncate', LINE_COLOR[e.kind])}>
{e.label}
</div>
))
)}
</div>
</Drawer>
{/* ── 4 · Advanced (runtime + channels), moved off the left column ── */}
<Drawer label="ADVANCED" meta="RUNTIME · CHANNELS" testid="rail-advanced">
<div className="space-y-3 rounded-[10px] border border-line bg-surface p-3">
<div>
<div className="font-mono text-[9.5px] tracking-[0.18em] text-ink-3">AGENT RUNTIME</div>
<div className="mt-2">
<OpenYourNode variant="hero" />
</div>
</div>
<TelegramSetup />
<VoiceSetup />
</div>
</Drawer>
</div>
)
}
+7 -20
View File
@@ -5,16 +5,15 @@ import { cn } from '@/lib/utils'
interface Step { interface Step {
key: PhaseKey key: PhaseKey
to: string to: string
eyebrow: string
label: string label: string
} }
const STEPS: Step[] = [ const STEPS: Step[] = [
{ key: 'reg', to: '/workshop', eyebrow: 'PHASE 1', label: 'Team registration' }, { key: 'reg', to: '/workshop', label: 'Team registration' },
{ key: 'setup', to: '/workshop/setup', eyebrow: 'PHASE 2', label: 'Meet your agent' }, { key: 'setup', to: '/workshop/setup', label: 'Meet your agent' },
{ key: 'm1', to: '/workshop/module1', eyebrow: 'PHASE 3', label: 'Module 1' }, { key: 'm1', to: '/workshop/module1', label: 'Module 1' },
{ key: 'm2', to: '/workshop/module2', eyebrow: 'PHASE 4', label: 'Module 2' }, { key: 'm2', to: '/workshop/module2', label: 'Module 2' },
{ key: 'add', to: '/workshop/add', eyebrow: 'PHASE 5', label: 'Module 3' }, { key: 'add', to: '/workshop/add', label: 'Module 3' },
] ]
/** /**
@@ -44,7 +43,7 @@ export function Stepper({ collapsed = false }: { collapsed?: boolean }) {
data-state={state} data-state={state}
data-phase={s.key} data-phase={s.key}
disabled={!reachable} disabled={!reachable}
title={collapsed ? `${s.eyebrow} · ${s.label}` : undefined} title={collapsed ? s.label : undefined}
onClick={() => reachable && navigate(s.to)} onClick={() => reachable && navigate(s.to)}
className={cn( className={cn(
'flex items-center gap-3 rounded-lg text-left transition-colors', 'flex items-center gap-3 rounded-lg text-left transition-colors',
@@ -67,20 +66,9 @@ export function Stepper({ collapsed = false }: { collapsed?: boolean }) {
{i + 1} {i + 1}
</span> </span>
{!collapsed && ( {!collapsed && (
<span className="min-w-0">
<span <span
className={cn( className={cn(
'block font-mono text-[9px] tracking-[0.16em]', 'block min-w-0 truncate text-[14px] leading-tight',
state === 'done' && 'text-green',
state === 'active' && 'text-blue-eyebrow',
state === 'pending' && 'text-faint',
)}
>
{s.eyebrow}
</span>
<span
className={cn(
'block truncate text-[14px] leading-tight',
state === 'done' && 'text-green font-medium', state === 'done' && 'text-green font-medium',
state === 'active' && 'text-blue-ink font-semibold', state === 'active' && 'text-blue-ink font-semibold',
state === 'pending' && 'text-[var(--muted-2)] font-medium', state === 'pending' && 'text-[var(--muted-2)] font-medium',
@@ -88,7 +76,6 @@ export function Stepper({ collapsed = false }: { collapsed?: boolean }) {
> >
{s.label} {s.label}
</span> </span>
</span>
)} )}
</button> </button>
) )
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react'
import type { AgentStatus } from './useAgentChat'
/**
* "Clawd" — the pixel-art orange crab mascot from Claude Code — animated on the
* LED matrix and coupled to the agent's chat lifecycle:
* • idle → still, staring — two permanently-black eye-tiles under the
* antennas (they don't blink); antennas stay put too
* • working → claws pump up and down, tucked in close to the body
* • responded → the whole crab flashes green for a beat (a reply landed)
*
* Clawd is authored on a 13×8 sub-grid and centered (a touch low) inside a finer
* GRID_W×GRID_H matrix, so he reads at ~half size with dark boxes around him.
* Frames are 8 rows × 13 chars ('#' = lit) flattened row-major.
*/
export const GRID_W = 26
export const GRID_H = 16
const SUB_W = 13
const SUB_H = 8
const OFF_X = Math.floor((GRID_W - SUB_W) / 2) // 6 — horizontally centered
const OFF_Y = 6 // nudged down from dead-center so he sits a little lower
/** Parse a 13×8 art block and stamp it, centered, into the full GRID_W×GRID_H field. */
function place(rows: string[]): boolean[] {
const out = Array<boolean>(GRID_W * GRID_H).fill(false)
for (let y = 0; y < SUB_H; y++) {
const row = rows[y] ?? ''
for (let x = 0; x < SUB_W; x++) {
if (row[x] === '#') out[(y + OFF_Y) * GRID_W + (x + OFF_X)] = true
}
}
return out
}
// Base crab: static antennas (r0–r1, c4/c8), shell (r2–r4), arms tucked in at
// c1/c11, legs (r5). The two eyes are permanently-black tiles at r3 c4 & c8 —
// directly under the antennas — so they read as dark pupils that never blink.
const BASE = [
'....#...#....',
'....#...#....',
'...#######...',
'.#.#.###.#.#.',
'.#.#######.#.',
'...#.#.#.#...',
'.............',
'.............',
]
// claws raised (arms up) — antennas + black eyes unchanged
const ARMS_UP = [
'....#...#....',
'....#...#....',
'.#.#######.#.',
'.#.#.###.#.#.',
'...#######...',
'...#.#.#.#...',
'.............',
'.............',
]
// claws dropped (arms down) — antennas + black eyes unchanged
const ARMS_DOWN = [
'....#...#....',
'....#...#....',
'...#######...',
'...#.###.#...',
'.#.#######.#.',
'.#.#.#.#.#.#.',
'.............',
'.............',
]
// idle is a single static stare (no blink); working pumps the claws
const IDLE_FRAMES = [BASE].map(place)
const WORK_FRAMES = [ARMS_UP, ARMS_DOWN].map(place)
const BASE_FRAME = place(BASE)
export interface ClawdFrame {
dots: boolean[]
color: 'orange' | 'green'
}
/**
* Drive Clawd from the agent status. Returns the current frame + the color the
* lit cells should take (green only during the post-reply flash).
*/
export function useClawd(status: AgentStatus): ClawdFrame {
const [i, setI] = useState(0)
// responded = held green flash; working = fast pump; idle = slow blink cadence
const fps = status === 'working' ? 7 : 4
useEffect(() => {
setI(0)
if (status === 'responded') return // hold a single green frame
const id = window.setInterval(() => setI((n) => n + 1), Math.round(1000 / fps))
return () => window.clearInterval(id)
}, [status, fps])
if (status === 'responded') return { dots: BASE_FRAME, color: 'green' }
const frames = status === 'working' ? WORK_FRAMES : IDLE_FRAMES
return { dots: frames[i % frames.length], color: 'orange' }
}
+151
View File
@@ -0,0 +1,151 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { sendPrompt, askNode, openTeamActivity, AGENT } from './api'
import type { NodeActivityKind, WsEvent } from '@/types'
/**
* One shared view of the conversation with the team's default agent, split into
* the concerns the UI keeps distinct:
* • messages — the chat (your turns + the agent's actual replies + a greeting)
* • logs — the full activity trace of the agent working (every event)
* • status — idle | working | responded, which also drives Clawd's animation
* • starters — per-prompt state for the canned "try these" buttons
*
* You send via the node's `/webhook` (`sendPrompt`); the agent's work streams
* back over the team SSE feed (`openTeamActivity`). Bookkeeping "Agent started /
* finished" lines are kept out of the chat (they belong in the logs) so the chat
* reads as an actual back-and-forth.
*/
export type AgentStatus = 'idle' | 'working' | 'responded'
export type StarterState = 'idle' | 'running' | 'done'
export type ChatMessage =
| { who: 'you'; text: string }
| { who: 'agent'; kind: NodeActivityKind; text: string }
export interface LogItem {
kind: NodeActivityKind
label: string
ts: string
}
export interface AgentChatState {
messages: ChatMessage[]
logs: LogItem[]
status: AgentStatus
sending: boolean
starters: Record<string, StarterState>
doneCount: number
/** Send a message; pass a starterId to track it as one of the canned prompts. */
send: (text: string, starterId?: string) => Promise<void>
}
const GREETING =
"Hi — I'm your APESS agent, running right here on your board. Ask me anything, or tap a starter below and watch me work the hardware."
// Kinds that read as an actual chat reply (vs. working noise).
const CHAT_KINDS = new Set<NodeActivityKind>(['response', 'fallback', 'error'])
// Kinds that mean "a reply landed" → the green flash.
const TERMINAL_OK = new Set<NodeActivityKind>(['response', 'fallback', 'flash'])
// Bookkeeping lines that belong in the logs, never the chat.
const NOISE = /^agent (started|finished)\b/i
const MAX_LOGS = 120
export function useAgentChat(teamId: string, enabled: boolean): AgentChatState {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [logs, setLogs] = useState<LogItem[]>([])
const [status, setStatus] = useState<AgentStatus>('idle')
const [sending, setSending] = useState(false)
const [starters, setStarters] = useState<Record<string, StarterState>>({})
const running = useRef<string | null>(null) // active starter id, if any
const flashTimer = useRef<number | undefined>(undefined)
// Greet once, so the chat opens as a conversation rather than an empty box.
useEffect(() => {
if (!enabled) return
setMessages((prev) => (prev.length ? prev : [{ who: 'agent', kind: 'response', text: GREETING }]))
}, [enabled])
useEffect(() => {
if (!enabled) return
return openTeamActivity(teamId, (ev: WsEvent) => {
if (ev.type !== 'node:activity') return
setLogs((prev) => [...prev, { kind: ev.kind, label: ev.label, ts: ev.ts }].slice(-MAX_LOGS))
// real replies go to the chat; "Agent started/finished" stays in the logs
if (CHAT_KINDS.has(ev.kind) && !NOISE.test(ev.label)) {
setMessages((prev) => [...prev, { who: 'agent', kind: ev.kind, text: ev.label }])
}
const active = running.current
if (ev.kind === 'error') {
setStatus('idle')
if (active) {
running.current = null
setStarters((s) => ({ ...s, [active]: 'idle' })) // let them retry
}
} else if (TERMINAL_OK.has(ev.kind)) {
setStatus('responded')
window.clearTimeout(flashTimer.current)
flashTimer.current = window.setTimeout(() => setStatus('idle'), 1200)
if (active) {
running.current = null
setStarters((s) => ({ ...s, [active]: 'done' }))
}
}
})
}, [teamId, enabled])
useEffect(() => () => window.clearTimeout(flashTimer.current), [])
const send = useCallback(
async (text: string, starterId?: string) => {
const t = text.trim()
if (!t) return
if (starterId) {
running.current = starterId
setStarters((s) => ({ ...s, [starterId]: 'running' }))
}
setMessages((prev) => [...prev, { who: 'you', text: t }])
setStatus('working')
setSending(true)
try {
if (starterId) {
// The canned starters are tool turns: fire-and-forget, and the tool
// result streams back over SSE. (The blocking webhook returns empty
// for tool turns, so we can't wait on it here.)
await sendPrompt(teamId, t, AGENT)
} else {
// A free-form message is usually conversational — its text reply is
// NOT emitted as an activity event, so we wait on the blocking path to
// get the actual answer. If it comes back empty (a tool turn), the SSE
// stream will carry the tool result instead.
const reply = (await askNode(teamId, t, AGENT)).trim()
if (reply) {
setMessages((prev) => [...prev, { who: 'agent', kind: 'response', text: reply }])
setStatus('responded')
window.clearTimeout(flashTimer.current)
flashTimer.current = window.setTimeout(() => setStatus('idle'), 1200)
}
}
} catch {
setMessages((prev) => [
...prev,
{ who: 'agent', kind: 'error', text: 'Could not reach your agent — is the board online?' },
])
setStatus('idle')
if (starterId) {
running.current = null
setStarters((s) => ({ ...s, [starterId]: 'idle' }))
}
} finally {
setSending(false)
}
},
[teamId],
)
const doneCount = Object.values(starters).filter((s) => s === 'done').length
return { messages, logs, status, sending, starters, doneCount, send }
}
-1
View File
@@ -62,7 +62,6 @@ export function AddBuilder() {
<section> <section>
<div className="print:hidden"> <div className="print:hidden">
<PanelHeading <PanelHeading
eyebrow="PHASE 5 OF 5 · ~90 MIN · DEADLINE 19:00"
title="Module 3 · Harness, Loops & submit" title="Module 3 · Harness, Loops & submit"
intro="Finish Layers 4 and 5 — how your node reasons and how it runs over time — review the assembled Agent Design Document, export a PDF, and submit before the deadline." intro="Finish Layers 4 and 5 — how your node reasons and how it runs over time — review the assembled Agent Design Document, export a PDF, and submit before the deadline."
size={44} size={44}
+10 -30
View File
@@ -4,10 +4,11 @@ import { MemoryRouter } from 'react-router-dom'
import { EnvSetup } from './EnvSetup' import { EnvSetup } from './EnvSetup'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
// EnvSetup now renders the agent chat + say-hi; stub the streaming/mode calls. // The chat, starters, runtime and channels all moved to the cockpit rail
// (CockpitRail's "agent" variant) — the page itself is now editorial copy + a
// proceed gate. Stub the mode call the rail would otherwise make elsewhere.
vi.mock('@/lib/api', async (orig) => ({ vi.mock('@/lib/api', async (orig) => ({
...(await orig<typeof import('@/lib/api')>()), ...(await orig<typeof import('@/lib/api')>()),
openTeamActivity: () => () => {},
getMode: () => Promise.resolve({ localMode: false }), getMode: () => Promise.resolve({ localMode: false }),
})) }))
@@ -29,44 +30,23 @@ describe('EnvSetup — Meet your agent', () => {
sessionStorage.clear() sessionStorage.clear()
}) })
it('renders the heading', () => { it('renders the heading and the editorial intro', () => {
renderPage() renderPage()
expect(screen.getByRole('heading', { name: /meet your agent/i })).toBeInTheDocument() expect(screen.getByRole('heading', { name: /meet your agent/i })).toBeInTheDocument()
expect(screen.getByTestId('agent-intro')).toBeInTheDocument()
}) })
it('prompts to claim a board first when not connected, and gates Proceed', () => { it('guides to connect the board first when not connected, and gates Proceed', () => {
renderPage() renderPage()
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument() expect(screen.getByText(/connect your board on the previous step/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled() expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
}) })
it('shows the node as Connected with the board url when claimed', () => { it('enables Proceed once the board is connected', () => {
connect('http://192.168.1.7:8080')
renderPage()
const link = screen.getByRole('link', { name: /open your node/i })
expect(link).toHaveAttribute('href', 'http://192.168.1.7:8080')
expect(link).toHaveAttribute('target', '_blank')
expect(screen.getByText(/^connected$/i)).toBeInTheDocument()
})
it('falls back to the claim prompt when connected but there is no nodeUrl', () => {
connect(null)
renderPage()
expect(screen.queryByRole('link', { name: /open your node/i })).not.toBeInTheDocument()
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
})
it('reveals the Your Agent section (say-hi + chat) once connected', () => {
connect()
renderPage()
expect(screen.getByTestId('your-agent')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /say hi to your agent/i })).toBeInTheDocument()
expect(screen.getByTestId('agent-chat')).toBeInTheDocument()
})
it('enables Proceed once the board is connected (domain moved to Module 1)', () => {
connect() connect()
renderPage() renderPage()
expect(screen.getByRole('button', { name: /proceed/i })).toBeEnabled() expect(screen.getByRole('button', { name: /proceed/i })).toBeEnabled()
// the connect nudge disappears once online
expect(screen.queryByText(/connect your board on the previous step/i)).toBeNull()
}) })
}) })
+23 -36
View File
@@ -1,16 +1,10 @@
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { OpenYourNode } from '@/components/OpenYourNode' import { PanelHeading, ProceedButton } from '@/components/cockpit/PanelChrome'
import { SayHiCard } from '@/components/SayHiCard'
import { TelegramSetup } from '@/components/TelegramSetup'
import { VoiceSetup } from '@/components/VoiceSetup'
import { AgentChat } from '@/components/AgentChat'
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
export function EnvSetup() { export function EnvSetup() {
const navigate = useNavigate() const navigate = useNavigate()
const device = useSession((s) => s.device) const device = useSession((s) => s.device)
const setTried = useSession((s) => s.setTried)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
const ready = device.connected const ready = device.connected
@@ -23,41 +17,34 @@ export function EnvSetup() {
return ( return (
<section> <section>
<PanelHeading <PanelHeading
eyebrow="PHASE 2 OF 5 · ~15 MIN"
title="Meet your agent" title="Meet your agent"
intro="Your board runs the APESS agent — a Claude-powered agent on the edge. Open it to explore, say hi, set up how you reach it, then put it to work on your real board." intro="Your board runs the APESS agent — a Claude-powered agent on the edge. Everything you need is in the cockpit on the right: chat with the agent, tap a starter to watch it work, and open the logs or the runtime when you want to look under the hood."
/> />
{/* Open your agent */} {/* Editorial copy — filler for now, keeps the left column balanced against the cockpit. */}
<PanelCard className="mt-9"> <div className="mt-8 max-w-[560px] space-y-5 text-[16.5px] leading-[1.7] text-ink-2" data-testid="agent-intro">
<div className="text-[17px] font-semibold">Open your agent to explore</div> <p>
<div className="mt-4"> Say hello and your agent answers from the board itself — no cloud round-trip required for the
<OpenYourNode variant="hero" /> basics. It already knows how to read its sensors, drive the LED matrix, and reason about what it
finds. The three starters on the right are the fastest way to see that in action: each one hands
the agent a real task and streams its work back to you.
</p>
<p>
Watch the crab while you chat. Clawd sits idle and blinks when nothing&rsquo;s happening, pumps his
claws while the agent is thinking, and flashes green the moment a reply lands — a small, honest
status light for the machine you&rsquo;re talking to.
</p>
<p>
When you&rsquo;re ready to go deeper, the logs drawer shows every tool call and result behind a
reply, and the runtime drawer opens the full ZeroClaw web interface running on your board. For now,
just say hi — the rest of the workshop builds on the agent you&rsquo;re meeting here.
</p>
</div> </div>
</PanelCard>
{device.connected ? ( {!ready && (
<div className="mt-8 space-y-5" data-testid="your-agent"> <p className="mt-6 font-mono text-[12px] tracking-[0.04em] text-ink-3">
<div> Connect your board on the previous step to bring your agent online.
<h2 className="text-[22px] font-semibold tracking-[-0.01em]">Your agent</h2>
<p className="mt-1 text-[15px] text-ink-2">
Say hi to the agent on your board, set up how you reach it — then chat with it and watch
it use its skills on the real hardware.
</p> </p>
</div>
<SayHiCard />
<div className="grid gap-5 md:grid-cols-2">
<TelegramSetup />
<VoiceSetup />
</div>
<AgentChat onProgress={(done) => setTried(done)} />
</div>
) : (
<PanelCard className="mt-6">
<p className="text-[14px] leading-[1.5] text-ink-3">
Connect your board on the previous step to meet your agent.
</p>
</PanelCard>
)} )}
<div className="mt-9 flex justify-end"> <div className="mt-9 flex justify-end">
-1
View File
@@ -24,7 +24,6 @@ export function Module1() {
return ( return (
<section> <section>
<PanelHeading <PanelHeading
eyebrow="PHASE 3 OF 5 · ~75 MIN"
title="Module 1 · Domain & events" 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." 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} size={46}
-1
View File
@@ -35,7 +35,6 @@ export function Module2() {
return ( return (
<section> <section>
<PanelHeading <PanelHeading
eyebrow="PHASE 4 OF 5 · ~90 MIN"
title="Module 2 · Skills & policies" title="Module 2 · Skills & policies"
intro="Capture what your agent can do and the policy that governs it — Layers 2 and 3 of your Agent Design Document." intro="Capture what your agent can do and the policy that governs it — Layers 2 and 3 of your Agent Design Document."
size={46} size={46}
-1
View File
@@ -46,7 +46,6 @@ export function TeamRegistration() {
return ( return (
<section> <section>
<PanelHeading <PanelHeading
eyebrow="PHASE 1 OF 5 · ~10 MIN"
title="Team registration" title="Team registration"
intro="Name your team, add 3–5 members, then bind the board you set up this week — run the app and enter the code it scrolls across its LED matrix." intro="Name your team, add 3–5 members, then bind the board you set up this week — run the app and enter the code it scrolls across its LED matrix."
/> />