import { Suspense, lazy, useEffect, useRef, useState, type ReactNode } from 'react' import { Alert } from '@/components/ui/Alert' import { errorMessage } from '@/lib/api' import { Button } from '@/components/ui/Button' import { TextArea } from '@/components/ui/TextArea' import { cn } from '@/lib/cn' import { describeStep, streamChat, useAgentHistory, useAgentRefresh, useClearAgentHistory, type AgentStep, type AgentTurn, } from '@/lib/agent' import { useUndo } from '@/lib/history' import { UndoButton } from './UndoButton' // Lazy so the markdown renderer + its ecosystem (~150 KB) loads only when an // assistant message actually renders, not for everyone who opens the editor. const MarkdownMessage = lazy(() => import('./MarkdownMessage').then((m) => ({ default: m.MarkdownMessage })), ) /** * Talk to the garden assistant, in the editor beside the canvas. * * Here rather than on its own page because watching the garden change as the * agent works IS the confirmation — which is what makes acting without asking * first tolerable. It also means the agent never has to guess which garden you * mean. */ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: boolean }) { const history = useAgentHistory(gardenId, true) const clear = useClearAgentHistory(gardenId) const refresh = useAgentRefresh(gardenId) const undo = useUndo(gardenId) const [input, setInput] = useState('') // The turn in flight: what we sent, the steps so far, and how it ended. const [pending, setPending] = useState<{ message: string; steps: AgentStep[] } | null>(null) const [error, setError] = useState(null) const [warning, setWarning] = useState(null) const abort = useRef(null) const bottom = useRef(null) // Deliberately NOT aborted on unmount. Selecting an object auto-switches the // rail to the inspector, so aborting here would mean clicking the canvas // mid-turn silently killed the turn — and the canvas is exactly what you're // meant to be watching. The request continues, the exchange is persisted // server-side, and coming back to this tab shows it. Only Stop aborts, and // even that only stops us READING: the turn keeps running server-side, which // is why its work still lands in History either way. // Follow the conversation as it grows, including mid-turn as steps arrive. useEffect(() => { bottom.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) }, [history.data, pending]) const send = () => { const message = input.trim() if (!message || pending) return setInput('') setError(null) setWarning(null) setPending({ message, steps: [] }) const controller = new AbortController() abort.current = controller void streamChat( gardenId, message, { onStep: (step) => { setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p)) // The canvas updating under the conversation is the whole point of // putting the chat here, so refresh it as each step lands — but only // it: nothing else can have changed until the turn commits. refresh.canvas() }, onDone: (turn: AgentTurn) => { setPending(null) refresh.everything() if (turn.truncated) { setError('That turned into more steps than I should take at once — check what changed before continuing.') } }, onWarning: setWarning, onError: (message) => { setPending(null) setError(message) // Something may still have landed before it failed. refresh.everything() }, }, controller.signal, ) } const messages = history.data ?? [] return (

Assistant

{messages.length > 0 && ( )}
{!canEdit && (

You can only view this garden, so the assistant can't change anything in it.

)}
{history.isPending &&

Loading the conversation…

} {/* A failed load rendering as an empty thread would look like the conversation had been lost, which is a much worse thing to believe. */} {history.isError && ( {errorMessage(history.error, "Couldn't load the conversation.")} )} {history.isSuccess && messages.length === 0 && !pending && (

Ask for what you want and it'll do it — “change the garlic bed to cucumbers this year”, “fill the north half of the west bed with beans”, “what's in here?”. Everything it does lands as one change you can undo.

)} {messages.map((m) => ( {/* Undo on the turn itself, so the common case never involves opening the History panel. Same hook #49 uses, not a second implementation. */} {m.role === 'assistant' && m.changeSetId != null && canEdit && ( )} ))} {pending && ( <>
    {pending.steps.map((s) => (
  1. {describeStep(s)}…
  2. ))}

{pending.steps.length === 0 ? 'Thinking…' : 'Working…'}

)} {warning && {warning}} {error && {error}}