import { Component, Suspense, useEffect, useRef, useState, type ReactNode } from 'react' import { Alert } from '@/components/ui/Alert' import { IconButton } from '@/components/ui/Button' import { errorMessage } from '@/lib/api' import { cn } from '@/lib/cn' import { describeStep, streamChat, useAgentHistory, useAgentRefresh, useClearAgentHistory, type AgentStep, type AgentTurn } from '@/lib/agent' import type { useUndo } from '@/lib/history' import { lazyPage } from '@/lib/lazyPage' // Lazy so the markdown renderer loads only when an assistant message renders. const MarkdownMessage = lazyPage(() => import('./MarkdownMessage'), 'MarkdownMessage') /** Falls back to the raw text if the markdown chunk can't load or throws. */ class MarkdownBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> { state = { failed: false } static getDerivedStateFromError() { return { failed: true } } render() { return this.state.failed ? this.props.fallback : this.props.children } } /** * Talk to the garden assistant beside the canvas — watching the plan change as * it works IS the confirmation. Every turn lands as one undoable step, so each * assistant reply that changed something carries its own Undo. */ export function AssistantTab({ gardenId, canEdit, undo, large = false }: { gardenId: number; canEdit: boolean; undo: ReturnType; large?: boolean }) { const history = useAgentHistory(gardenId, true) const clear = useClearAgentHistory(gardenId) const refresh = useAgentRefresh(gardenId) const [input, setInput] = useState('') 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) const thread = useRef(null) // Whether the view is pinned to the end of the thread. It follows new // content only while it is; a person who scrolled up to read something is // left there until they come back down or send the next message. const stuck = useRef(true) // Deliberately NOT aborted on unmount: selecting a bed switches the rail to // the inspector, and that must not kill a turn mid-flight. The request runs // on; the exchange is persisted server-side; coming back shows it. // Instant, not smooth: Chrome left the thread at the top with // `behavior: 'smooth'` — a smooth scrollIntoView into this nested scroller // never moved it, so every new reply landed out of view below a long // conversation (found live, 2026-08-23). The instant form scrolls. It runs // on every step of a turn too, so it is gated on `stuck`: following the // stream is right when the person is at the end, and a snap they didn't // ask for when they had scrolled up. useEffect(() => { if (stuck.current) bottom.current?.scrollIntoView({ block: 'end' }) }, [history.data, pending]) const onThreadScroll = () => { const el = thread.current if (el) stuck.current = el.scrollHeight - el.clientHeight - el.scrollTop < 80 } const send = () => { const message = input.trim() if (!message || pending) return setInput('') stuck.current = true // sending is a return to the end of the thread 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)) 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: (m) => { setPending(null) setError(m) refresh.everything() }, }, controller.signal, ) } const stop = () => { abort.current?.abort() setPending(null) refresh.everything() } const messages = history.data ?? [] const bubbleText = large ? 'text-[13.5px]' : 'text-[13px]' return (
{/* The thread scrolls on its own so the composer stays put: with the whole tab scrolling, a long conversation pushed the input off the bottom and every new message scrolled it further away. */}
{!canEdit && You can only view this garden, so the assistant can't change anything in it.} {history.isPending &&

Loading the conversation…

} {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) => m.role === 'user' ? (
{m.body}
) : (
{/* min-w-0 / max-w-full: a wide markdown table scrolls inside its own wrapper instead of widening the bubble past the panel. */}
{m.body}}> {m.body}}> {m.body}
{m.changeSetId != null && canEdit && }
), )} {pending && ( <>
{pending.message}
{pending.steps.map((s) => (
{describeStep(s)}…
))}

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

)} {warning && {warning}} {error && {error}}
{messages.length > 0 && !pending && ( )}
setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() send() } }} /> {pending ? ( ) : ( )}
) } /** Undo on the turn itself, so the common case never involves the History tab. */ function TurnUndo({ changeSetId, undo }: { changeSetId: number; undo: ReturnType }) { const outcome = undo.outcomeFor(changeSetId) return (
{outcome && outcome.tone !== 'pending' && (

{outcome.message}

)}
) }