The assistant answers in Markdown — tables, lists, bold, links — but the chat bubble showed the raw source (whitespace-pre-wrap), so a table came through as a wall of pipes. - New MarkdownMessage: react-markdown + remark-gfm (for GFM tables). It does NOT enable raw HTML (no rehype-raw), so a model reply can't inject markup; every element the assistant actually uses is Tailwind-styled for a chat bubble, and wide content (tables, code) scrolls in its own box so the bubble can't blow out. - Only ASSISTANT bubbles render as Markdown; the user's own text stays literal (their `*` shouldn't become a bullet) and the assistant bubble goes full-width so a table has room. - Lazy-loaded: the renderer + its ecosystem (~150 KB) is its own async chunk, loaded only when an assistant message renders — not for everyone who opens the editor. To keep it out of the eager first paint, manualChunks now vendors only the app-wide core (react/react-dom/scheduler kept together — splitting react-dom breaks React 19 at load) and lets everything else ride with its importer. Tested via renderToStaticMarkup (node, no DOM): a GFM table renders with styled cells + horizontal scroll; **bold**/lists render; raw <script>/<b> are escaped not emitted; links get rel="noopener noreferrer". App re-verified to mount cleanly with the re-chunk (no React-19 init-order break). tsc + vitest (99) + build green, no >500 KB warning. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
259 lines
9.2 KiB
TypeScript
259 lines
9.2 KiB
TypeScript
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<string | null>(null)
|
|
const [warning, setWarning] = useState<string | null>(null)
|
|
const abort = useRef<AbortController | null>(null)
|
|
const bottom = useRef<HTMLDivElement>(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 (
|
|
<div className="flex h-full flex-col gap-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h2 className="text-sm font-semibold text-fg">Assistant</h2>
|
|
{messages.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
clear.mutate(undefined, {
|
|
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
|
|
})
|
|
}
|
|
disabled={clear.isPending}
|
|
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
>
|
|
Start over
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{!canEdit && (
|
|
<p className="rounded-md bg-border/40 px-2 py-1 text-xs text-muted">
|
|
You can only view this garden, so the assistant can't change anything in it.
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
|
{history.isPending && <p className="text-sm text-muted">Loading the conversation…</p>}
|
|
{/* 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 && (
|
|
<Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>
|
|
)}
|
|
|
|
{history.isSuccess && messages.length === 0 && !pending && (
|
|
<p className="text-sm text-muted">
|
|
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.
|
|
</p>
|
|
)}
|
|
|
|
{messages.map((m) => (
|
|
<Bubble key={m.id} role={m.role} body={m.body}>
|
|
{/* 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 && (
|
|
<UndoButton changeSet={{ id: m.changeSetId }} undo={undo} className="mt-1 items-start" />
|
|
)}
|
|
</Bubble>
|
|
))}
|
|
|
|
{pending && (
|
|
<>
|
|
<Bubble role="user" body={pending.message} />
|
|
<div className="rounded-lg border border-border px-2.5 py-2 text-sm">
|
|
<ol className="flex flex-col gap-0.5">
|
|
{pending.steps.map((s) => (
|
|
<li key={s.index} className="text-xs text-muted">
|
|
{describeStep(s)}…
|
|
</li>
|
|
))}
|
|
</ol>
|
|
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted">
|
|
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
|
|
{pending.steps.length === 0 ? 'Thinking…' : 'Working…'}
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{warning && <Alert tone="info">{warning}</Alert>}
|
|
{error && <Alert>{error}</Alert>}
|
|
<div ref={bottom} />
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2 border-t border-border pt-2">
|
|
<TextArea
|
|
label="Message"
|
|
name="agentMessage"
|
|
rows={2}
|
|
placeholder="Change the garlic bed to cucumbers this year"
|
|
value={input}
|
|
disabled={!!pending}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
// Enter sends, Shift+Enter breaks the line — the convention every
|
|
// chat box uses, and typing a newline by accident mid-thought is a
|
|
// worse failure than the reverse.
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
send()
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex justify-end gap-2">
|
|
{pending && (
|
|
<Button
|
|
variant="ghost"
|
|
className="px-2 py-1 text-xs"
|
|
onClick={() => {
|
|
abort.current?.abort()
|
|
setPending(null)
|
|
refresh.everything()
|
|
}}
|
|
>
|
|
Stop
|
|
</Button>
|
|
)}
|
|
<Button className="px-3 py-1.5 text-sm" disabled={!!pending || input.trim() === ''} onClick={send}>
|
|
{pending ? 'Working…' : 'Send'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Bubble({
|
|
role,
|
|
body,
|
|
children,
|
|
}: {
|
|
role: 'user' | 'assistant'
|
|
body: string
|
|
children?: ReactNode
|
|
}) {
|
|
const mine = role === 'user'
|
|
return (
|
|
<div className={cn('flex flex-col', mine ? 'items-end' : 'items-start')}>
|
|
<div
|
|
className={cn(
|
|
'rounded-lg px-2.5 py-2 text-sm',
|
|
// The user's own text is literal (their `*` shouldn't become a bullet)
|
|
// and hugs the right; the assistant's Markdown is rendered and gets the
|
|
// full width so a table has room.
|
|
mine
|
|
? 'max-w-[90%] whitespace-pre-wrap bg-accent/15 text-fg'
|
|
: 'w-full border border-border text-fg',
|
|
)}
|
|
>
|
|
{mine ? (
|
|
body
|
|
) : (
|
|
// Show the raw text until the renderer chunk arrives, so a message never
|
|
// flashes blank.
|
|
<Suspense fallback={<span className="whitespace-pre-wrap">{body}</span>}>
|
|
<MarkdownMessage>{body}</MarkdownMessage>
|
|
</Suspense>
|
|
)}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|