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}} +
+
+ +
+