Compare commits
2
Commits
5975287d56
...
0156f97b36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0156f97b36 | ||
|
|
a9a5176f7c |
@@ -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.
|
||||
Executable
+65
@@ -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."
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -111,7 +111,7 @@ export function CockpitLayout() {
|
||||
</Suspense>
|
||||
</aside>
|
||||
) : (
|
||||
<CockpitRail />
|
||||
<CockpitRail variant={pathname === '/workshop/setup' ? 'agent' : 'full'} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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' : '—'}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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’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’re talking to.
|
||||
</p>
|
||||
<p>
|
||||
When you’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’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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user