feat(workshop): local-demo landing + consolidated Phase 1 + live Telegram setup

Reframes the app for the local single-team demo and reworks onboarding.

Landing (local-demo chrome):
- Drop Lecture / staff sign-in / programme / stat tiles / bottom CTA.
- Hero → "an APESS agent on the edge"; CTA → "Start the workshop".
- Presenter keeps redclaw.dev + [email protected] contact.

Phase strip:
- Colour by position off the active phase (earlier=done, current=active,
  later=pending) so a stale/persisted flag can't light up a future phase
  green (fixes "phase 2 green while on phase 1").

Phase 1 (Team registration) — consolidated onboarding:
- After the board connects, reveal Say hi (moved from Phase 2), Extra
  channels (Telegram wizard), and a Voice enable/disable toggle.
- Phase 2 "Meet your node" thins to Open-your-node + Pick-your-domain;
  removed the Lock-it-down card. New session `channels {telegram, voice}`
  (persist v3, non-destructive migration).

Live Telegram apply (browser → APESS API → board ZeroClaw gateway):
- Wizard Finish pushes the bot token to the running node and restarts it:
  PUT /api/config/prop channels.telegram.main.bot_token (gateway
  auto-creates the alias + enc2-encrypts the secret) then POST
  /admin/reload (in-place subsystem reload → the Telegram channel starts).
- api: bridge configureTelegram + POST /nodes/:teamId/telegram; client
  configureTelegram(); wizard shows applying/error and only saves on
  success. Remote /admin/reload needs the board's gateway.allow_remote_admin
  (+ pairing); on a fully-open board the reload step 403s.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-22 00:24:05 -07:00
co-authored by Claude Opus 4.8
parent 6386884842
commit 063116507b
19 changed files with 687 additions and 362 deletions
+4 -132
View File
@@ -1,4 +1,3 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -7,38 +6,14 @@ import { PhaseStrip } from '@/components/PhaseStrip'
import { OpenYourNode } from '@/components/OpenYourNode'
import { DomainPicker } from '@/components/DomainPicker'
import { useSession } from '@/store/session'
import { sayHi } from '@/lib/api'
import { cn } from '@/lib/utils'
type HiState = 'idle' | 'running' | 'ok'
export function EnvSetup() {
const navigate = useNavigate()
const teamId = useSession((s) => s.teamId)
const device = useSession((s) => s.device)
const domain = useSession((s) => s.domain)
const completePhase = useSession((s) => s.completePhase)
const [hi, setHi] = useState<HiState>('idle')
const [reply, setReply] = useState('')
const [hiError, setHiError] = useState<string | null>(null)
const saidHi = async () => {
if (hi === 'running') return
setHi('running')
setHiError(null)
setReply('')
try {
// Chat with the agent on the team's own board; a reply means it's live.
const r = await sayHi(teamId, 'cloud')
setReply(r || '(your node replied)')
setHi('ok')
} catch (e) {
setHiError(e instanceof Error ? e.message : 'Could not reach your node.')
setHi('idle')
}
}
const ready = device.connected && hi === 'ok' && domain.trim().length > 0
const ready = device.connected && domain.trim().length > 0
const onProceed = () => {
completePhase('setup')
@@ -66,8 +41,8 @@ export function EnvSetup() {
</Badge>
<h1 className="text-3xl font-bold tracking-tight">Meet your node</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
Your board is a node running its own agent. Open it, say hi, name the domain it&rsquo;s
for then you&rsquo;re clear for Module 1.
Open your node&rsquo;s own dashboard and name the domain it&rsquo;s for then
you&rsquo;re clear for Module 1.
</p>
</div>
@@ -81,65 +56,7 @@ export function EnvSetup() {
</CardContent>
</Card>
{/* 2 — Say hi to your agent */}
<Card>
<CardHeader>
<CardTitle className="text-base">Say hi to your agent</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground leading-relaxed">
Say hi to the agent running on your board. When it replies, your node is live and
listening and you&rsquo;re clear to name its domain.
</p>
{/* the exchange */}
{(hi !== 'idle' || reply) && (
<div className="space-y-2" data-testid="say-hi-chat">
<div className="flex justify-end">
<span className="rounded-lg bg-primary/10 text-foreground px-3 py-1.5 text-sm max-w-[80%]">Hi 👋</span>
</div>
{hi === 'running' && (
<div className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber animate-pulse" />
your node is thinking
</div>
)}
{reply && (
<div className="flex justify-start">
<span className="rounded-lg border border-teal/40 bg-teal/5 px-3 py-1.5 text-sm max-w-[80%]" data-testid="node-reply">
{reply}
</span>
</div>
)}
</div>
)}
<Button
variant={hi === 'ok' ? 'outline' : 'default'}
className="w-full justify-start"
disabled={!device.connected || hi === 'running'}
onClick={saidHi}
>
<span
className={cn(
'w-2 h-2 rounded-full mr-3',
hi === 'ok' ? 'bg-teal' : hi === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
)}
/>
{hi === 'ok' ? 'Your node replied ✓' : hi === 'running' ? 'Waiting for your node…' : 'Say hi to your agent'}
</Button>
{hiError && (
<p role="alert" className="text-xs text-red-500 leading-relaxed">{hiError}</p>
)}
{!device.connected && (
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Bind your board in team registration first.
</p>
)}
</CardContent>
</Card>
{/* 3 — Pick your domain */}
{/* 2 — Pick your domain */}
<Card>
<CardHeader>
<CardTitle className="text-base">Pick your domain</CardTitle>
@@ -149,51 +66,6 @@ export function EnvSetup() {
</CardContent>
</Card>
{/* 4 — Extra channels (optional) */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Extra channels <span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">optional</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground leading-relaxed">
<p>
Your dashboard is the main way in, but you can reach your node other ways too:
</p>
<ul className="space-y-2">
<li>
<span className="font-medium text-foreground">Telegram</span> make a bot with{' '}
<span className="font-mono text-xs">@BotFather</span>, then paste its token in the node
dashboard Config channels.
</li>
<li>
<span className="font-medium text-foreground">Voice</span> use the mic button in the
node dashboard to talk to it directly.
</li>
</ul>
</CardContent>
</Card>
{/* 5 — Lock it down (policy moment) */}
<Card>
<CardHeader>
<CardTitle className="text-base">Lock it down</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground leading-relaxed">
<p>
Your board boots <span className="font-medium text-foreground">open</span> so setup is
frictionless anyone on the LAN can reach it right now. That&rsquo;s your first{' '}
<span className="font-medium text-foreground">policy</span> decision: when setup is done,
harden it so only your group can talk to it.
</p>
<p>
Run <span className="font-mono text-xs">zeroclaw-lockdown.sh</span> on the board it mints
a pair code your group uses to reconnect. Leave it open for now; you&rsquo;ll revisit this
once you&rsquo;ve designed the agent&rsquo;s policies.
</p>
</CardContent>
</Card>
<div className="flex justify-end pt-4">
<Button size="lg" disabled={!ready} onClick={onProceed}>
Proceed to Module 1