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]>
185 lines
7.3 KiB
TypeScript
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’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>
|
|
)
|
|
}
|