Compare commits

...
2 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 4.8 0156f97b36 feat(uno-q): expose participant files to the agent + document mux scan
- Mount each student's own App Lab files into the app container so the
  agent can read/fix them: mount-user-workspace.sh binds ~/sketches (rw),
  ~/ArduinoApps/* (rw), ~/Arduino/libraries (ro) under /app/workspace via
  a root oneshot ordered before arduino-app-cli.service (the /app bind is
  rprivate, so binds must precede container start; App Lab has no app.yaml
  volumes field). Wired into provision-node-app, provision-fleet (systemd,
  best-effort sudo), and package-onboard-app (bundled under host-setup/).
- uno-q-hardware skill: document the mux-aware i2c_scan output format
  (0x70:mux, 0x70.2=0x1d) and tell the agent its student's files live at
  /app/workspace. See USER-WORKSPACE.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 16:35:17 -07:00
Omar SobhandClaude Opus 4.8 a9a5176f7c 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]>
2026-07-23 16:35:17 -07:00
20 changed files with 948 additions and 148 deletions
+80
View File
@@ -0,0 +1,80 @@
# Exposing participant files to the agent (`/app/workspace`)
Goal: let the containerized agent **read, correct, and complete a student's own
Arduino code**. By default it can't — the App Lab container only mounts its own app
directory. This wires the student's files in.
## Where participants store their files (Uno Q, via App Lab)
| Location on the board | What it holds | Created when… |
|---|---|---|
| `~/sketches/<name>/<name>.ino` | **Sketches** from App Lab's sketch editor — the primary place implementations live | student opens the sketch editor and saves |
| `~/ArduinoApps/<name>/` | Full **App Lab apps** (`app.yaml` + `python/` + `sketch/` + `web/`) | student runs *New App* |
| `~/Arduino/libraries/` | Installed Arduino **libraries** | library manager / `arduino-cli lib install` |
(Our own node is `~/ArduinoApps/apess-onboard` — excluded from the mount to avoid a
recursive self-mount.)
## Why a bind-mount, and why at boot
- App Lab's `app.yaml` has **no `volumes` field**; it generates the compose itself
(`.cache/app-compose.yaml`) with a fixed hardware mount profile — no injection point.
- The container's `/app` bind is **`rprivate`**, so a host submount added *after* the
container starts does **not** propagate in. The binds must exist **before** the app
container is created.
- `mount(2)` is privileged → this runs as **root at boot, ordered before
`arduino-app-cli.service`** (the App Lab daemon that launches the default app).
Result inside the container:
```
/app/workspace/sketches/ <- ~/sketches (rw)
/app/workspace/apps/<name>/ <- ~/ArduinoApps/<name> (rw, minus apess-onboard)
/app/workspace/libraries/ <- ~/Arduino/libraries (ro)
```
The agent is told about this in the `uno-q-hardware` skill ("The student's own files").
## Install (on the board, once — needs root)
```sh
# copy the mount script + unit onto the board
adb push deploy/uno-q/mount-user-workspace.sh /home/arduino/mount-user-workspace.sh
adb shell 'chmod +x /home/arduino/mount-user-workspace.sh'
adb push deploy/uno-q/systemd/apess-user-workspace.service /tmp/apess-user-workspace.service
adb shell 'sudo install /tmp/apess-user-workspace.service /etc/systemd/system/ \
&& sudo systemctl daemon-reload \
&& sudo systemctl enable --now apess-user-workspace.service'
# the binds only reach the ALREADY-running container after it is recreated
# (rprivate), so restart the app once:
adb shell 'arduino-app-cli app restart /home/arduino/ArduinoApps/apess-onboard'
# verify
adb shell 'docker exec apess-onboard-main-1 ls -la /app/workspace/sketches'
```
After this it survives reboots (the unit runs before the app each boot).
## Live vs. restart
- **New sketches** (`~/sketches/...`) appear **live** — they're files inside the
single `~/sketches` bind, not new mounts. No restart needed.
- A **new sibling App Lab app** is a new mount → re-run the script and restart the
app: `sudo /home/arduino/mount-user-workspace.sh && arduino-app-cli app restart …`.
## Wired into provisioning
- **`provision-fleet.sh`** (`MODE=systemd`) — pushes the script + unit, `sudo -n`
installs/enables it, and restarts the app, per board. Falls back to a staged-file
message if root isn't available (mount can't run cron-only).
- **`provision-node-app.sh`** — same, for a single dev board (targets whichever app
it provisions via `.apess-workspace.env` → `APP_DIR`).
- **`package-onboard-app.sh`** — bundles `mount-user-workspace.sh` +
`apess-user-workspace.service` into the app under `host-setup/` (plus this doc as
`host-setup/README.md`), since App Lab self-import can't run root steps. The
epilogue prints the one-time enable command for imported boards.
The unit reads `APP_DIR` from `/home/arduino/.apess-workspace.env` (default
`…/apess-onboard`), so the same unit works for both the distributable and the dev app.
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Expose each participant's own Arduino files INTO the apess-onboard app container
# so the agent can read, correct, and complete their implementation.
#
# WHY THIS EXISTS
# The App Lab container only bind-mounts the app's own directory
# (/home/arduino/ArduinoApps/apess-onboard -> /app). A student's real work lives
# elsewhere and is invisible to the agent:
# ~/sketches/<name>/<name>.ino App Lab "sketch editor" projects (primary)
# ~/ArduinoApps/<name>/ full App Lab apps
# ~/Arduino/libraries/ installed libraries
# App Lab has NO volumes field in app.yaml and generates the compose itself, so
# we can't declare these there. Instead we bind the user paths UNDERNEATH the app
# dir (which /app already maps). The catch: the /app bind is `rprivate`, so a
# submount added AFTER the container starts does NOT propagate in — the binds must
# exist BEFORE the app container is created. Hence a root oneshot ordered
# Before=arduino-app-cli.service (see apess-user-workspace.service).
#
# RESULT INSIDE THE CONTAINER
# /app/workspace/sketches/ <- ~/sketches (rw)
# /app/workspace/apps/<name>/ <- ~/ArduinoApps/<name> (rw, minus ourselves)
# /app/workspace/libraries/ <- ~/Arduino/libraries (ro, reference)
#
# Idempotent — safe to re-run. Requires root (mount(2) is privileged).
set -euo pipefail
USER_HOME=${USER_HOME:-/home/arduino}
APP_DIR=${APP_DIR:-$USER_HOME/ArduinoApps/apess-onboard}
WS="$APP_DIR/workspace"
SELF=$(basename "$APP_DIR")
if [ "$(id -u)" -ne 0 ]; then
echo "must run as root (mount is privileged) — try: sudo $0" >&2
exit 1
fi
bind() { # src dst [ro]
local src=$1 dst=$2 ro=${3:-}
if [ ! -d "$src" ]; then echo "skip (no source dir): $src"; return 0; fi
mkdir -p "$dst"
if mountpoint -q "$dst"; then echo "already mounted: $dst"; return 0; fi
mount --bind "$src" "$dst"
[ "$ro" = ro ] && mount -o remount,ro,bind "$dst"
echo "mounted: $src -> $dst${ro:+ (ro)}"
}
mkdir -p "$WS" "$WS/apps"
chown "$(stat -c '%u:%g' "$USER_HOME")" "$WS" "$WS/apps" 2>/dev/null || true
bind "$USER_HOME/sketches" "$WS/sketches"
bind "$USER_HOME/Arduino/libraries" "$WS/libraries" ro
# Each OTHER App Lab app — skip ourselves so we don't recursively self-mount.
if [ -d "$USER_HOME/ArduinoApps" ]; then
for d in "$USER_HOME/ArduinoApps"/*/; do
[ -d "$d" ] || continue
name=$(basename "$d")
[ "$name" = "$SELF" ] && continue
bind "$d" "$WS/apps/$name"
done
fi
echo "workspace ready: $WS"
echo "note: a NEW sibling app created after boot needs a re-run + app restart to appear"
echo " (rprivate /app); new *sketches* in ~/sketches appear live, no restart needed."
+20
View File
@@ -58,6 +58,17 @@ ok "config (single 'default' agent, matrix + i2c_scan)"
cp -r "$HERE/skills" "$OUT/.zeroclaw/shared/skills"
ok "skills ($(ls "$HERE/skills" | wc -l | tr -d ' ') bundles)"
# Host-setup helpers that CAN'T ride the container: the participant-workspace
# bind-mount (root, before app start) that exposes ~/sketches + ~/ArduinoApps +
# libraries at /app/workspace so the agent can fix student code. App Lab import
# can't run these (no root), so they travel in host-setup/ for a one-time enable.
mkdir -p "$OUT/host-setup/systemd"
cp "$HERE/mount-user-workspace.sh" "$OUT/host-setup/mount-user-workspace.sh"
cp "$HERE/systemd/apess-user-workspace.service" "$OUT/host-setup/systemd/apess-user-workspace.service"
cp "$HERE/USER-WORKSPACE.md" "$OUT/host-setup/README.md"
chmod +x "$OUT/host-setup/mount-user-workspace.sh"
ok "host-setup/ (participant-workspace mount — enable once per board, needs root)"
# BAKED cloud token (per the workshop decision) — the instructor's Max token,
# shared across the fleet. Kept in the app bundle only, never in the repo.
printf '%s' "$ANTHROPIC_OAUTH_TOKEN" > "$OUT/.zeroclaw/oauth_token"
@@ -104,6 +115,15 @@ Distribute it:
Students download it, open App Lab → "Import an app" → pick the zip → Run.
• Instructor smoke-test on a board:
arduino-app-cli app import "$ZIP"
• Expose participant files to the agent (/app/workspace) — one-time, needs root
(App Lab import can't do this itself). On each board after import:
adb push <app>/host-setup/mount-user-workspace.sh /home/arduino/ && \\
adb shell 'chmod +x /home/arduino/mount-user-workspace.sh' && \\
adb push <app>/host-setup/systemd/apess-user-workspace.service /tmp/ && \\
adb shell 'sudo install /tmp/apess-user-workspace.service /etc/systemd/system/ \\
&& sudo systemctl enable --now apess-user-workspace.service \\
&& arduino-app-cli app restart /home/arduino/ArduinoApps/apess-onboard'
Fleet boards: provision-fleet.sh does this automatically (MODE=systemd). See host-setup/README.md.
• APESS_URL: default is mDNS apess-api.local. Set per team by editing
.zeroclaw/apess-node.env before packaging, or pass APESS_URL=http://<laptop>:3000.
EOF
+17
View File
@@ -77,6 +77,23 @@ provision() { # kit serial -> 0 ok / 1 fail
echo " ok — modalities (reload-watcher up; lockdown staged)"
fi
# participant workspace — bind ~/sketches + ~/ArduinoApps + ~/Arduino/libraries
# into the app container (/app/workspace) so the agent can read/fix student code.
# mount(2) is root-only and can't fall back to cron, so this is systemd-only,
# best-effort. See USER-WORKSPACE.md.
if [ -r "$HERE/mount-user-workspace.sh" ]; then
adb -s "$serial" push "$HERE/mount-user-workspace.sh" /home/arduino/ >/dev/null 2>&1
adb -s "$serial" shell 'chmod +x /home/arduino/mount-user-workspace.sh' >/dev/null 2>&1
adb -s "$serial" push "$HERE/systemd/apess-user-workspace.service" /tmp/ >/dev/null 2>&1
if adb -s "$serial" shell 'sudo -n cp /tmp/apess-user-workspace.service /etc/systemd/system/ \
&& sudo -n systemctl daemon-reload && sudo -n systemctl enable --now apess-user-workspace.service' >/dev/null 2>&1; then
adb -s "$serial" shell 'TMPDIR=/tmp arduino-app-cli app restart /home/arduino/ArduinoApps/apess-onboard >/dev/null 2>&1 || true' >/dev/null 2>&1
echo " ok — participant workspace mounted (/app/workspace)"
else
echo " ! workspace mount needs root (sudo -n failed) — staged; enable apess-user-workspace.service on the board"
fi
fi
# default skills — install every bundled skill (the comprehensive arduino-uno-q
# reference + the fork's granular set) into every agent's workspace, so each
# node has them by default. Best-effort.
+24
View File
@@ -10,11 +10,13 @@
#
# Env: SERIAL (65301572), NODE_APP_DIR (repo app dir), plus the node-env vars above.
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
SERIAL="${SERIAL:-65301572}"
NODE_APP_DIR="${NODE_APP_DIR:-$HOME/projects/zeroclaw/firmware/zeroclaw-node}"
DEST=/home/arduino/ArduinoApps/zeroclaw-node
S(){ adb -s "$SERIAL" shell "$@"; }
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn(){ printf ' \033[33m!\033[0m %s\n' "$*"; }
bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; }
adb -s "$SERIAL" get-state >/dev/null 2>&1 || { bad "board $SERIAL not attached"; exit 1; }
@@ -91,6 +93,28 @@ S "cd $DEST && TMPDIR=/tmp timeout 300 arduino-app-cli app start $DEST 2>&1 | ta
S 'crontab -l 2>/dev/null | grep -v "zeroclaw-supervisor" | crontab - 2>/dev/null; for p in $(pgrep -f "[z]eroclaw-supervisor"); do kill -9 $p 2>/dev/null; done'
ok "removed legacy supervisor @reboot cron (App Lab app owns boot now)"
echo "→ exposing participant files to the agent (/app/workspace)"
# Bind ~/sketches + ~/ArduinoApps + ~/Arduino/libraries under this app dir so the
# agent can read/fix student code. mount(2) is root-only and the App Lab /app bind
# is rprivate (submounts must precede the container), so this is a root oneshot
# ordered before arduino-app-cli.service. See USER-WORKSPACE.md.
adb -s "$SERIAL" push "$HERE/mount-user-workspace.sh" /home/arduino/mount-user-workspace.sh >/dev/null 2>&1
S "chmod +x /home/arduino/mount-user-workspace.sh"
adb -s "$SERIAL" push "$HERE/systemd/apess-user-workspace.service" /tmp/apess-user-workspace.service >/dev/null 2>&1
printf 'APP_DIR=%s\n' "$DEST" | S "cat > /home/arduino/.apess-workspace.env" # this app dir maps to /app
if S 'sudo -n cp /tmp/apess-user-workspace.service /etc/systemd/system/ \
&& sudo -n systemctl daemon-reload \
&& sudo -n systemctl enable --now apess-user-workspace.service' >/dev/null 2>&1; then
# rprivate: the running container must be recreated to pick up the new binds.
S "cd $DEST && TMPDIR=/tmp arduino-app-cli app restart $DEST >/dev/null 2>&1 || true"
ok "workspace mounted → agent sees ~/sketches, ~/ArduinoApps, ~/Arduino/libraries at /app/workspace"
else
warn "workspace mount needs root — sudo unavailable over adb. Files are staged; enable once on the board:"
echo " sudo install /tmp/apess-user-workspace.service /etc/systemd/system/ \\"
echo " && sudo systemctl enable --now apess-user-workspace.service \\"
echo " && arduino-app-cli app restart $DEST"
fi
echo "→ enable Run-at-startup for boot persistence:"
echo " adb -s $SERIAL shell 'arduino-app-cli properties set default $DEST'"
ok "provisioned. In App Lab, open 'ZeroClaw Node' → Run."
@@ -32,6 +32,42 @@ Sketches always target the MCU (`arduino:zephyr:unoq`).
- `analogRead()` returns 0–1023; volts = `raw * 3.3 / 1023.0`.
- For a 5 V sensor, divide down: 5 V → 10 kΩ → A0 → 20 kΩ → GND.
## Checking sensors — the `i2c_scan` tool (mux-aware)
To see what's wired to the board's I2C, call **`i2c_scan`**. It probes the MCU's
Arduino Wire bus (Qwiic + I2C headers) — this is where student sensors hang, NOT
Linux `/dev/i2c-*` (those are MPU-side and unreachable from the app container).
The APESS kit hangs its ADXL355s behind a **PCA9548A I2C mux at `0x70`**, and two
sensors can share address `0x1d` on different channels — so the scan walks the mux
too. Read the comma-separated result like this:
- `0x1d` — a device directly on the bus (e.g. a lone ADXL355 wired to Qwiic).
- `0x70:mux` — an I2C mux is present at `0x70`.
- `0x70.2=0x1d` — a device at `0x1d` behind mux `0x70` on **channel 2**.
- `none` — nothing ACKed.
So `0x70:mux,0x70.2=0x1d,0x70.5=0x1d` = the mux plus two ADXL355s, one on channel 2
and one on channel 5. If a student sees only `0x70:mux`, their sensors aren't wired
to the mux channels (or aren't powered) — a mux with nothing behind it. If they see
nothing at all, check power and SDA/SCL. **ADXL355** = `0x1d` (or `0x1e` if ADDR is
pulled high); the FabLab kit reads it at `0x1d`.
## The student's own files — help fix their implementation
The participant's Arduino work is mounted into this container under **`/app/workspace/`**:
- `/app/workspace/sketches/<name>/<name>.ino` — sketches they wrote in App Lab's
sketch editor (this is where most implementations live).
- `/app/workspace/apps/<name>/` — full App Lab apps they built.
- `/app/workspace/libraries/` — installed Arduino libraries (read-only reference).
Read these to review, correct, and complete a student's code when they ask for help
("why doesn't my sensor read?", "fix my sketch"). You can edit files under
`sketches/` and `apps/`; `libraries/` is reference only. If `/app/workspace/` is
empty, the workspace mounts aren't set up on this board yet — say so rather than
guessing at their code.
## On-board LEDs
- RGB LED 1/2 are MPU-owned (`/sys/class/leds/*`, use the `sysfs_led` tool).
@@ -0,0 +1,21 @@
[Unit]
Description=APESS — bind participant workspace into the apess-onboard app container
# The app's /app bind is rprivate, so these submounts must exist BEFORE the App Lab
# daemon starts the default app container. Order strictly before it.
Before=arduino-app-cli.service
After=home-arduino.mount local-fs.target
RequiresMountsFor=/home/arduino
[Service]
Type=oneshot
RemainAfterExit=yes
# Which app dir maps to /app. Default = the distributable apess-onboard; the dev
# provisioner overrides it (to zeroclaw-node) by writing .apess-workspace.env.
Environment=APP_DIR=/home/arduino/ArduinoApps/apess-onboard
EnvironmentFile=-/home/arduino/.apess-workspace.env
ExecStart=/home/arduino/mount-user-workspace.sh
# Clean unmount on stop so the next start rebinds fresh.
ExecStop=/bin/sh -c 'for m in "$APP_DIR"/workspace/sketches "$APP_DIR"/workspace/libraries "$APP_DIR"/workspace/apps/*; do mountpoint -q "$m" && umount "$m" || true; done; exit 0'
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -111,7 +111,7 @@ export function CockpitLayout() {
</Suspense>
</aside>
) : (
<CockpitRail />
<CockpitRail variant={pathname === '/workshop/setup' ? 'agent' : 'full'} />
)}
</div>
</div>
+131 -26
View File
@@ -1,9 +1,13 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useSession } from '@/store/session'
import { useNodeFeed } from '@/lib/useNodeFeed'
import { useTelemetry } from '@/lib/useTelemetry'
import { useMatrixMirror } from '@/lib/useMatrixMirror'
import { useClawd, GRID_W } from '@/lib/clawSprite'
import { useAgentChat } from '@/lib/useAgentChat'
import { WaveformCanvas } from './WaveformCanvas'
import { RailAgent } from './RailAgent'
import type { NodeActivityKind } from '@/types'
import { cn } from '@/lib/utils'
import { ADD_LAYERS } from '@/lib/addLayers'
@@ -33,31 +37,46 @@ const NEXT_BY_PATH: Record<string, string[]> = {
'/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 (
<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>
)
}
export function CockpitRail() {
export function CockpitRail({ variant = 'full' }: { variant?: 'full' | 'agent' } = {}) {
const teamId = useSession((s) => s.teamId)
const team = useSession((s) => s.team)
const connected = useSession((s) => s.device.connected)
const add = useSession((s) => s.add)
const submitted = useSession((s) => s.submission.code != null)
const setTried = useSession((s) => s.setTried)
const { pathname } = useLocation()
const feed = useNodeFeed(teamId, connected)
const tel = useTelemetry(connected)
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
// to the sim frame until the first real frame arrives.
const mirror = useMatrixMirror(teamId, online)
const matrixDots = mirror ?? tel.matrix
const mirror = useMatrixMirror(teamId, online && !agentView)
const matrixDots = agentView ? claw.dots : mirror ?? tel.matrix
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 nextSet = new Set(NEXT_BY_PATH[pathname] ?? [])
@@ -68,47 +87,95 @@ export function CockpitRail() {
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 (
<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 */}
<div className="flex items-center gap-[11px]">
<span
className={cn(
'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
className={cn(
'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'}
</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>
{/* 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="flex items-center justify-between">
<div className="font-mono text-[9.5px] tracking-[0.18em] text-rail-dim">LED MATRIX · 13×8</div>
{matrixLive && (
<span className="font-mono text-[8.5px] tracking-[0.12em] text-rail-green">● MIRROR</span>
<div className={cn('font-mono text-[9.5px] tracking-[0.18em]', label)}>
{agentView ? 'LED MATRIX · 26×16' : 'LED MATRIX · 13×8'}
</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 className="mt-2 flex justify-center rounded-[10px] border border-rail-line bg-rail-inset px-[13px] py-3">
<div className="grid gap-1" style={{ gridTemplateColumns: 'repeat(13, 1fr)' }}>
{Array.from({ length: 104 }).map((_, i) => {
const lit = tel.live && matrixDots[i]
<div
className={cn(
'mt-2 rounded-[10px] border border-rail-line bg-rail-inset',
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 (
<span
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={{
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',
background: lit ? litColor : 'oklch(0.28 0.01 260)',
boxShadow: lit ? `0 0 5px ${litColor}` : 'none',
}}
/>
)
@@ -117,6 +184,9 @@ export function CockpitRail() {
</div>
</div>
{/* 3 & 4 · sensor telemetry — omitted on the "agent" variant (Meet your agent) */}
{variant !== 'agent' && (
<>
{/* 3 · I2C bus (telemetry) */}
<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">
@@ -178,8 +248,20 @@ export function CockpitRail() {
))}
</div>
</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">
<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 ? (
@@ -193,20 +275,43 @@ export function CockpitRail() {
)}
</div>
</RailSection>
)}
{/* 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]">
{ADD_LAYERS.map(({ key, n, title }) => {
const st = layerState(key)
return (
<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 className={cn(st === 'idle' ? 'text-rail-dim2' : 'text-rail-text2')}>{title}</span>
<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
className={cn(
'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' : '—'}
+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({
eyebrow,
title,
intro,
size = 52,
}: {
eyebrow: string
/** Deprecated — the phase chip was removed; kept optional so callers don't break. */
eyebrow?: string
title: string
intro?: string
size?: number
}) {
return (
<div>
<Eyebrow>{eyebrow}</Eyebrow>
<h1
className="mt-[22px] font-semibold leading-[1.03] tracking-[-0.02em] text-ink"
style={{ fontSize: size }}
>
<h1 className="font-semibold leading-[1.03] tracking-[-0.02em] text-ink" style={{ fontSize: size }}>
{title}
</h1>
{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 {
key: PhaseKey
to: string
eyebrow: string
label: string
}
const STEPS: Step[] = [
{ key: 'reg', to: '/workshop', eyebrow: 'PHASE 1', label: 'Team registration' },
{ key: 'setup', to: '/workshop/setup', eyebrow: 'PHASE 2', label: 'Meet your agent' },
{ key: 'm1', to: '/workshop/module1', eyebrow: 'PHASE 3', label: 'Module 1' },
{ key: 'm2', to: '/workshop/module2', eyebrow: 'PHASE 4', label: 'Module 2' },
{ key: 'add', to: '/workshop/add', eyebrow: 'PHASE 5', label: 'Module 3' },
{ key: 'reg', to: '/workshop', label: 'Team registration' },
{ key: 'setup', to: '/workshop/setup', label: 'Meet your agent' },
{ key: 'm1', to: '/workshop/module1', label: 'Module 1' },
{ key: 'm2', to: '/workshop/module2', label: 'Module 2' },
{ key: 'add', to: '/workshop/add', label: 'Module 3' },
]
/**
@@ -44,7 +43,7 @@ export function Stepper({ collapsed = false }: { collapsed?: boolean }) {
data-state={state}
data-phase={s.key}
disabled={!reachable}
title={collapsed ? `${s.eyebrow} · ${s.label}` : undefined}
title={collapsed ? s.label : undefined}
onClick={() => reachable && navigate(s.to)}
className={cn(
'flex items-center gap-3 rounded-lg text-left transition-colors',
@@ -67,20 +66,9 @@ export function Stepper({ collapsed = false }: { collapsed?: boolean }) {
{i + 1}
</span>
{!collapsed && (
<span className="min-w-0">
<span
className={cn(
'block font-mono text-[9px] tracking-[0.16em]',
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',
'block min-w-0 truncate text-[14px] leading-tight',
state === 'done' && 'text-green font-medium',
state === 'active' && 'text-blue-ink font-semibold',
state === 'pending' && 'text-[var(--muted-2)] font-medium',
@@ -88,7 +76,6 @@ export function Stepper({ collapsed = false }: { collapsed?: boolean }) {
>
{s.label}
</span>
</span>
)}
</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>
<div className="print:hidden">
<PanelHeading
eyebrow="PHASE 5 OF 5 · ~90 MIN · DEADLINE 19:00"
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."
size={44}
+10 -30
View File
@@ -4,10 +4,11 @@ import { MemoryRouter } from 'react-router-dom'
import { EnvSetup } from './EnvSetup'
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) => ({
...(await orig<typeof import('@/lib/api')>()),
openTeamActivity: () => () => {},
getMode: () => Promise.resolve({ localMode: false }),
}))
@@ -29,44 +30,23 @@ describe('EnvSetup — Meet your agent', () => {
sessionStorage.clear()
})
it('renders the heading', () => {
it('renders the heading and the editorial intro', () => {
renderPage()
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()
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()
})
it('shows the node as Connected with the board url when claimed', () => {
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)', () => {
it('enables Proceed once the board is connected', () => {
connect()
renderPage()
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 { OpenYourNode } from '@/components/OpenYourNode'
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 { PanelHeading, ProceedButton } from '@/components/cockpit/PanelChrome'
import { useSession } from '@/store/session'
export function EnvSetup() {
const navigate = useNavigate()
const device = useSession((s) => s.device)
const setTried = useSession((s) => s.setTried)
const completePhase = useSession((s) => s.completePhase)
const ready = device.connected
@@ -23,41 +17,34 @@ export function EnvSetup() {
return (
<section>
<PanelHeading
eyebrow="PHASE 2 OF 5 · ~15 MIN"
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 */}
<PanelCard className="mt-9">
<div className="text-[17px] font-semibold">Open your agent to explore</div>
<div className="mt-4">
<OpenYourNode variant="hero" />
{/* Editorial copy — filler for now, keeps the left column balanced against the cockpit. */}
<div className="mt-8 max-w-[560px] space-y-5 text-[16.5px] leading-[1.7] text-ink-2" data-testid="agent-intro">
<p>
Say hello and your agent answers from the board itself — no cloud round-trip required for the
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>
</PanelCard>
{device.connected ? (
<div className="mt-8 space-y-5" data-testid="your-agent">
<div>
<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.
{!ready && (
<p className="mt-6 font-mono text-[12px] tracking-[0.04em] text-ink-3">
Connect your board on the previous step to bring your agent online.
</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">
-1
View File
@@ -24,7 +24,6 @@ export function Module1() {
return (
<section>
<PanelHeading
eyebrow="PHASE 3 OF 5 · ~75 MIN"
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}
-1
View File
@@ -35,7 +35,6 @@ export function Module2() {
return (
<section>
<PanelHeading
eyebrow="PHASE 4 OF 5 · ~90 MIN"
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."
size={46}
-1
View File
@@ -46,7 +46,6 @@ export function TeamRegistration() {
return (
<section>
<PanelHeading
eyebrow="PHASE 1 OF 5 · ~10 MIN"
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."
/>