From 6c12dcfe2a94d0ffdf73589caab800c3c3db3fc6 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Tue, 21 Jul 2026 02:19:27 -0400 Subject: [PATCH] Chat panel in the garden editor (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversational surface, in the editor rather than on its own page. The reason is the feedback loop: watching the canvas change as the agent works IS the confirmation, which is exactly what makes "act freely without asking first" tolerable. It also means the agent never has to guess which garden you mean. Tool calls surface as they happen, in the app's own vocabulary — "Clearing a bed", "Looking up a plant" — not raw tool names or JSON. That is the difference between the panel feeling like it's doing something and feeling like it's hung, which matters because a replant makes a dozen calls over tens of seconds. An unknown tool degrades to readable words rather than showing snake_case at the user, so the client can lag the server by a tool without looking broken. The canvas refreshes as each step lands, not just at the end. Refreshing only on completion would put the whole point of siting the chat here — watching it work — behind the same wait that streaming exists to remove. Undo sits on the turn itself, so the common case never involves opening the History panel. It uses #49's useUndo, not a second implementation, which meant giving that hook an UndoTarget: the chat knows a turn's change set id but not its tally, and fabricating counts to satisfy the type would have produced a confidently wrong "1 of 1 changes undone". describeUndo now says "Partly undone" when it has no denominator rather than inventing one. The panel is only offered when the instance actually has the assistant configured, via a new /capabilities read. The routes 404 without a key, so without this the client would have to probe for a 404 to find out — and a tab that opens onto an apology is worse than no tab. Streaming is hand-rolled over fetch rather than EventSource, which can only issue GETs and this needs a POST body. The wire format is still SSE, so a proxy that understands it doesn't buffer and the server wouldn't change if EventSource became viable. Partial frames are buffered across chunks and a malformed frame is skipped rather than killing a working stream. Errors read as sentences, and as different sentences: a permission refusal, a timeout and a model failure want different reactions. Every failure path refreshes, because something may have landed before it failed — and says where to look for it. Closes #57 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- DESIGN.md | 3 +- internal/api/api.go | 6 + web/src/editor/ChatPanel.tsx | 218 +++++++++++++++++++++++++++++ web/src/editor/UndoButton.tsx | 4 +- web/src/lib/agent.test.ts | 29 ++++ web/src/lib/agent.ts | 188 +++++++++++++++++++++++++ web/src/lib/history.test.ts | 26 ++++ web/src/lib/history.ts | 24 +++- web/src/pages/GardenEditorPage.tsx | 22 +++ 9 files changed, 513 insertions(+), 7 deletions(-) create mode 100644 web/src/editor/ChatPanel.tsx create mode 100644 web/src/lib/agent.test.ts create mode 100644 web/src/lib/agent.ts diff --git a/DESIGN.md b/DESIGN.md index ebb46f4..f217298 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -69,6 +69,7 @@ GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator POST /agent/chat ← SSE: step events, then the finished turn (editor only) GET,DELETE /gardens/:id/agent/history (the actor's own thread) +GET /capabilities ← what this instance can do, so the UI offers only what works GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email) ``` @@ -118,7 +119,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac - **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`. - **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag. - **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`. -- **One rail, tabs inside it.** The inspector, history, journal, and later the chat panel all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested. +- **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested. ## Roadmap diff --git a/internal/api/api.go b/internal/api/api.go index 021b5ea..22e013b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -53,6 +53,12 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine { // is set; see csrfGuard). v1.Use(h.csrfGuard()) v1.GET("/healthz", healthz) + // What this instance can actually do, so the UI offers only what works. The + // agent routes 404 when unconfigured; without this the client would have to + // probe for a 404 to find that out, and a dead button is worse than no button. + v1.GET("/capabilities", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"agent": cfg.Agent.Ready()}) + }) // Auth endpoints are exempt from requireAuth (you can't be logged in yet); // /me is the one that needs a session. Feature routers in later issues attach diff --git a/web/src/editor/ChatPanel.tsx b/web/src/editor/ChatPanel.tsx new file mode 100644 index 0000000..709461e --- /dev/null +++ b/web/src/editor/ChatPanel.tsx @@ -0,0 +1,218 @@ +import { useEffect, useRef, useState } from 'react' +import { Alert } from '@/components/ui/Alert' +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' + +/** + * 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 abort = useRef(null) + const bottom = useRef(null) + + // Abort an in-flight turn when the panel goes away, so a closed tab doesn't + // leave a read hanging. + useEffect(() => () => abort.current?.abort(), []) + + // 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) + 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 as each step lands, not just at the end: the canvas updating + // under the conversation is the whole point of putting the chat here. + refresh() + }, + onDone: (turn: AgentTurn) => { + setPending(null) + refresh() + if (turn.truncated) { + setError('That turned into more steps than I should take at once — check what changed before continuing.') + } + }, + onError: (message) => { + setPending(null) + setError(message) + // Something may still have landed before it failed. + refresh() + }, + }, + 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.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…'} +

+
+ + )} + + {error && {error}} +
+
+ +
+