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" cp -r "$HERE/skills" "$OUT/.zeroclaw/shared/skills"
ok "skills ($(ls "$HERE/skills" | wc -l | tr -d ' ') bundles)" 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, # 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. # shared across the fleet. Kept in the app bundle only, never in the repo.
printf '%s' "$ANTHROPIC_OAUTH_TOKEN" > "$OUT/.zeroclaw/oauth_token" 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. Students download it, open App Lab → "Import an app" → pick the zip → Run.
• Instructor smoke-test on a board: • Instructor smoke-test on a board:
arduino-app-cli app import "$ZIP" 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 • 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. .zeroclaw/apess-node.env before packaging, or pass APESS_URL=http://<laptop>:3000.
EOF EOF
+17
View File
@@ -77,6 +77,23 @@ provision() { # kit serial -> 0 ok / 1 fail
echo " ok — modalities (reload-watcher up; lockdown staged)" echo " ok — modalities (reload-watcher up; lockdown staged)"
fi 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 # default skills — install every bundled skill (the comprehensive arduino-uno-q
# reference + the fork's granular set) into every agent's workspace, so each # reference + the fork's granular set) into every agent's workspace, so each
# node has them by default. Best-effort. # 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. # Env: SERIAL (65301572), NODE_APP_DIR (repo app dir), plus the node-env vars above.
set -u set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
SERIAL="${SERIAL:-65301572}" SERIAL="${SERIAL:-65301572}"
NODE_APP_DIR="${NODE_APP_DIR:-$HOME/projects/zeroclaw/firmware/zeroclaw-node}" NODE_APP_DIR="${NODE_APP_DIR:-$HOME/projects/zeroclaw/firmware/zeroclaw-node}"
DEST=/home/arduino/ArduinoApps/zeroclaw-node DEST=/home/arduino/ArduinoApps/zeroclaw-node
S(){ adb -s "$SERIAL" shell "$@"; } S(){ adb -s "$SERIAL" shell "$@"; }
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; } ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn(){ printf ' \033[33m!\033[0m %s\n' "$*"; }
bad(){ printf ' \033[31m✗\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; } 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' 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)" 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 "→ enable Run-at-startup for boot persistence:"
echo " adb -s $SERIAL shell 'arduino-app-cli properties set default $DEST'" echo " adb -s $SERIAL shell 'arduino-app-cli properties set default $DEST'"
ok "provisioned. In App Lab, open 'ZeroClaw Node' → Run." 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`. - `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. - 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 ## On-board LEDs
- RGB LED 1/2 are MPU-owned (`/sys/class/leds/*`, use the `sysfs_led` tool). - 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> </Suspense>
</aside> </aside>
) : ( ) : (
<CockpitRail /> <CockpitRail variant={pathname === '/workshop/setup' ? 'agent' : 'full'} />
)} )}
</div> </div>
</div> </div>
+144 -39
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,35 +248,70 @@ 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 */}
<RailSection label="AGENT ACTIVITY"> {agentView ? (
<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]"> <RailAgent
{log.length === 0 ? ( messages={chat.messages}
<div className="text-rail-dim2">idle — prompt your agent to see it work</div> logs={chat.logs}
) : ( sending={chat.sending}
log.map((e, i) => ( online={online}
<div key={i} className={cn('truncate', LOG_COLOR[e.kind])}> starters={chat.starters}
{e.label} onSend={(t, id) => void chat.send(t, id)}
</div> />
)) ) : (
)} <RailSection label="AGENT ACTIVITY">
</div> <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]">
</RailSection> {log.length === 0 ? (
<div className="text-rail-dim2">idle — prompt your agent to see it work</div>
) : (
log.map((e, i) => (
<div key={i} className={cn('truncate', LOG_COLOR[e.kind])}>
{e.label}
</div>
))
)}
</div>
</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>
)
}
+15 -28
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,27 +66,15 @@ 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 min-w-0 truncate text-[14px] leading-tight',
'block font-mono text-[9px] tracking-[0.16em]', state === 'done' && 'text-green font-medium',
state === 'done' && 'text-green', state === 'active' && 'text-blue-ink font-semibold',
state === 'active' && 'text-blue-eyebrow', state === 'pending' && 'text-[var(--muted-2)] font-medium',
state === 'pending' && 'text-faint', )}
)} >
> {s.label}
{s.eyebrow}
</span>
<span
className={cn(
'block 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',
)}
>
{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()
}) })
}) })
+25 -38
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
</div> finds. The three starters on the right are the fastest way to see that in action: each one hands
</PanelCard> 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>
{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>
<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>
</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."
/> />