Compare commits
2
Commits
| 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"
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -111,7 +111,7 @@ export function CockpitLayout() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</aside>
|
</aside>
|
||||||
) : (
|
) : (
|
||||||
<CockpitRail />
|
<CockpitRail variant={pathname === '/workshop/setup' ? 'agent' : 'full'} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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' : '—'}
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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 {
|
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>
|
||||||
|
|||||||
@@ -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>
|
<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
@@ -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
@@ -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’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>
|
||||||
|
|
||||||
{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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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."
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user