Files
apress/src/components/AgentChat.tsx
T
Omar SobhandClaude Opus 4.8 5a140ccb17 refactor(agent): route every interaction through one agent constant (AGENT)
Say-hi, the module chat, and Refine each hard-coded the 'cloud' agent
while Telegram ran on 'demo' — different agents for web vs Telegram, easy
to get wrong. Introduce a single `AGENT = 'default'` in api.ts and use it
everywhere. 'default' is the node's fallback agent (the one used when no
alias is given), fully loaded with the cloud model + all skills + all
tools — so no call site can pick a different or missing agent.

Board side (config): 'default' is now the sole enabled agent and owns the
telegram.default channel; 'cloud' and 'demo' are disabled and any stray
request for them falls back to 'default'.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 10:11:40 -07:00

185 lines
7.3 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { useSession } from '@/store/session'
import { sendPrompt, openTeamActivity, AGENT } from '@/lib/api'
import type { NodeActivityKind, WsEvent } from '@/types'
/**
* The three canned prompts, in order. Each drives the agent to use its skills +
* tools on the real board: enumerate the I2C bus, then two LED-matrix sketches.
* KEEP THESE IMPERATIVE — the small on-board model reliably calls tools when told
* to *do* something but stalls when *asked a question* (measured on the board).
*/
const PROMPTS: { id: string; text: string }[] = [
{ id: 'i2c', text: 'List the I2C devices on the bus' },
{ id: 'count', text: 'Count to 100 and print the value once a second in the LED matrix' },
{ id: 'scroll', text: 'Scroll GO CLAWS on the LED matrix' },
]
type Status = 'idle' | 'running' | 'done'
type Line =
| { who: 'you'; text: string }
| { who: 'agent'; kind: NodeActivityKind; text: string }
const KIND_DOT: Record<NodeActivityKind, string> = {
thinking: 'bg-muted-foreground',
tool: 'bg-amber',
flash: 'bg-primary',
error: 'bg-destructive',
response: 'bg-teal',
fallback: 'bg-rose',
}
// A prompt is "done" once the agent reaches a terminal step for it.
const isSuccess = (k: NodeActivityKind) => k === 'flash' || k === 'response'
export interface AgentChatProps {
/** Called whenever the set of successfully-tried prompts changes. */
onProgress?: (doneCount: number, total: number) => void
}
/**
* A chat window straight to the team's agent. Click a canned prompt and watch
* the agent use its skills/tools on the real board — its activity streams back
* as the reply. Tracks which of the three prompts have completed successfully.
*/
export function AgentChat({ onProgress }: AgentChatProps) {
const teamId = useSession((s) => s.teamId)
const [lines, setLines] = useState<Line[]>([])
const [status, setStatus] = useState<Record<string, Status>>({ i2c: 'idle', count: 'idle', scroll: 'idle' })
const running = useRef<string | null>(null)
const scroller = useRef<HTMLDivElement>(null)
const doneCount = Object.values(status).filter((s) => s === 'done').length
const busy = running.current !== null || Object.values(status).some((s) => s === 'running')
// Report progress without making the parent's inline callback a dependency
// (that would re-fire every render and loop with the parent's setState).
const onProgressRef = useRef(onProgress)
onProgressRef.current = onProgress
useEffect(() => {
onProgressRef.current?.(doneCount, PROMPTS.length)
}, [doneCount])
useEffect(() => {
if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight
}, [lines])
// Stream this team's own board activity as the agent's replies.
useEffect(() => {
return openTeamActivity(teamId, (ev: WsEvent) => {
if (ev.type !== 'node:activity') return
setLines((prev) => [...prev, { who: 'agent', kind: ev.kind, text: ev.label }])
const active = running.current
if (!active) return
if (ev.kind === 'error') {
running.current = null
setStatus((s) => ({ ...s, [active]: 'idle' })) // let them retry
} else if (isSuccess(ev.kind)) {
running.current = null
setStatus((s) => ({ ...s, [active]: 'done' }))
}
})
}, [teamId])
const run = async (id: string, text: string) => {
if (busy) return
running.current = id
setStatus((s) => ({ ...s, [id]: 'running' }))
setLines((prev) => [...prev, { who: 'you', text }])
try {
await sendPrompt(teamId, text, AGENT)
} catch {
running.current = null
setStatus((s) => ({ ...s, [id]: 'idle' }))
setLines((prev) => [
...prev,
{ who: 'agent', kind: 'error', text: 'Could not reach your agent — is the board online?' },
])
}
}
return (
<Card data-testid="agent-chat">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Chat with your agent</CardTitle>
<Badge variant="default" className="font-mono text-[10px] uppercase tracking-widest">
{doneCount}/{PROMPTS.length} tried
</Badge>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground leading-relaxed">
Send one of these to your agent and watch it use its skills and tools on the real board —
its activity streams back here. Try all three to see what it can already do.
</p>
{/* canned prompts */}
<div className="flex flex-col gap-2">
{PROMPTS.map((p) => {
const st = status[p.id]
return (
<button
key={p.id}
type="button"
data-testid={`prompt-${p.id}`}
data-state={st}
disabled={busy && st !== 'running'}
onClick={() => void run(p.id, p.text)}
className={cn(
'flex items-center gap-2.5 text-left rounded-md border px-3 py-2 text-sm transition-colors disabled:opacity-50',
st === 'done'
? 'border-teal/40 bg-teal/10'
: st === 'running'
? 'border-amber/40 bg-amber/5'
: 'border-border hover:border-primary/40 hover:bg-muted',
)}
>
<span
className={cn(
'w-2 h-2 rounded-full shrink-0',
st === 'done' ? 'bg-teal' : st === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground/50',
)}
/>
<span className="flex-1">{p.text}</span>
<span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground shrink-0">
{st === 'done' ? 'done ✓' : st === 'running' ? 'running…' : 'send →'}
</span>
</button>
)
})}
</div>
{/* transcript */}
<div
ref={scroller}
data-testid="chat-transcript"
className="rounded-md border border-border bg-background/50 p-3 h-56 overflow-y-auto space-y-2"
>
{lines.length === 0 ? (
<p className="font-mono text-[11px] text-muted-foreground">
Pick a prompt above — your agent&rsquo;s work (tools, flashes, replies) shows up here.
</p>
) : (
lines.map((l, i) =>
l.who === 'you' ? (
<div key={i} className="flex justify-end">
<span className="rounded-lg bg-primary/10 text-foreground px-3 py-1.5 text-sm max-w-[85%]">{l.text}</span>
</div>
) : (
<div key={i} className="flex items-start gap-2 font-mono text-[11px]">
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0 mt-1.5', KIND_DOT[l.kind])} />
<span className={cn(l.kind === 'error' && 'text-destructive', l.kind === 'flash' && 'text-foreground font-medium')}>
{l.text}
</span>
</div>
),
)
)}
</div>
</CardContent>
</Card>
)
}