feat(cockpit): Clawd mascot + live agent chat on Meet your agent
Rework the Meet-your-agent rail into a self-contained agent surface: - Clawd, the Claude Code pixel crab, animates the LED matrix on a 26x16 grid, coupled to the chat lifecycle (idle stare / claws pump while working / green flash on reply). Static antennas, permanent black eyes. - A full free-form chat to the default agent (useAgentChat): free text goes to the blocking /webhook path so conversational replies actually render; the 3 canned prompts stay fire-and-forget + SSE (tool turns). - Chat and the working log are separate panes; the 3 prompts became starter buttons; the ZeroClaw runtime + channels moved into an Advanced drawer. The whole agent rail is now theme-aware (light/dark). - Strip the phase eyebrows from the sidebar + page headers; reduce the EnvSetup left column to editorial copy. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5975287d56
commit
a9a5176f7c
@@ -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,8 +248,20 @@ 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 */}
|
||||||
|
{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">
|
<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]">
|
<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 ? (
|
{log.length === 0 ? (
|
||||||
@@ -193,20 +275,43 @@ export function CockpitRail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</RailSection>
|
</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,20 +66,9 @@ 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 font-mono text-[9px] tracking-[0.16em]',
|
'block min-w-0 truncate text-[14px] leading-tight',
|
||||||
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',
|
|
||||||
state === 'done' && 'text-green font-medium',
|
state === 'done' && 'text-green font-medium',
|
||||||
state === 'active' && 'text-blue-ink font-semibold',
|
state === 'active' && 'text-blue-ink font-semibold',
|
||||||
state === 'pending' && 'text-[var(--muted-2)] font-medium',
|
state === 'pending' && 'text-[var(--muted-2)] font-medium',
|
||||||
@@ -88,7 +76,6 @@ export function Stepper({ collapsed = false }: { collapsed?: boolean }) {
|
|||||||
>
|
>
|
||||||
{s.label}
|
{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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+23
-36
@@ -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
|
||||||
|
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>
|
</div>
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
{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 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>
|
</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