Chat panel in the garden editor (#57) (#71)
Build image / build-and-push (push) Successful in 6s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #71.
This commit is contained in:
2026-07-21 06:43:22 +00:00
committed by steve
parent 3a3ce16fce
commit 8dbbc5439d
10 changed files with 604 additions and 10 deletions
+2 -1
View File
@@ -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
+4 -1
View File
@@ -73,6 +73,8 @@ The garden assistant reads three more. Setting none of them leaves the assistant
The assistant acts without asking first, which is only reasonable because every turn is one undoable change set — see the History panel in the editor.
**If you set the key and the assistant still doesn't appear**, check that the variable reaches the *container*, not just your orchestrator's stack config — Compose needs it listed under the service's `environment:`. pansy logs `garden assistant disabled` at startup with which of the three conditions failed, so the answer is in the first few lines of the log.
Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`, `/auth/logout`, `GET /auth/me`, `GET /auth/providers`); the session is an HttpOnly cookie (`Secure` when `PANSY_BASE_URL` is https). The first account registered becomes admin, and it may register even when `PANSY_REGISTRATION=closed` to bootstrap the instance.
OIDC (Authentik-first) is live too: set `PANSY_OIDC_ISSUER`, `PANSY_OIDC_CLIENT_ID`, `PANSY_OIDC_CLIENT_SECRET`, and `PANSY_BASE_URL` (needed for the redirect URI). Register `PANSY_BASE_URL` + `/api/v1/auth/oidc/callback` as the redirect URI in your IdP. `GET /auth/oidc/login` starts an authorization-code + PKCE flow; first login provisions a user just-in-time (a matching *verified* email links to an existing local account instead of duplicating it). Provider discovery is lazy, so a briefly-unreachable IdP never blocks startup or local auth. Set `PANSY_LOCAL_AUTH=false` for pure-Authentik deployments (local register/login are then rejected and hidden from `/auth/providers`).
@@ -111,7 +113,8 @@ services:
# PANSY_OIDC_ISSUER: https://auth.example.com/application/o/pansy/
# PANSY_OIDC_CLIENT_ID: ...
# PANSY_OIDC_CLIENT_SECRET: ...
# OLLAMA_CLOUD_API_KEY: ... # enables the garden assistant
# OLLAMA_CLOUD_API_KEY: ${OLLAMA_CLOUD_API_KEY} # enables the garden assistant
# PANSY_AGENT_MODEL: ollama-cloud/glm-5.2:cloud
restart: unless-stopped
volumes:
pansy-data:
+25
View File
@@ -53,6 +53,10 @@ 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.
// Registered after the agent below, because it reports whether the runner
// actually built — not merely whether it was configured to.
v1.GET("/capabilities", h.capabilities)
// 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
@@ -125,6 +129,17 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
// The garden assistant, registered only when it can actually be offered —
// the same shape as OIDC. An instance with no API key serves the app
// normally and simply doesn't have these routes.
if !cfg.Agent.Ready() {
// Say WHY, at startup, in the logs an operator is already looking at.
// Someone who set the key and sees no assistant otherwise has nothing to
// check — and "is the variable reaching the container?" is exactly the
// question they need answered.
slog.Info("api: garden assistant disabled",
"enabled", cfg.Agent.Enabled,
"hasApiKey", cfg.Agent.OllamaCloudAPIKey != "",
"model", cfg.Agent.Model,
"hint", "needs OLLAMA_CLOUD_API_KEY set in the container's environment (not just the stack's)")
}
if cfg.Agent.Ready() {
runner, err := agent.NewRunner(svc, cfg)
if err != nil {
@@ -180,6 +195,16 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
return r
}
// capabilities reports what this instance can actually do, so the UI offers only
// what works.
//
// It reports whether the runner BUILT, not whether it was configured: a
// configured-but-unresolvable model leaves the routes unregistered, and saying
// "yes" there would offer a chat tab whose first message 404s.
func (h *handlers) capabilities(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"agent": h.agent != nil})
}
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
func healthz(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
+239
View File
@@ -0,0 +1,239 @@
import { 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'
/**
* 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(
'max-w-[90%] whitespace-pre-wrap rounded-lg px-2.5 py-2 text-sm',
mine ? 'bg-accent/15 text-fg' : 'border border-border text-fg',
)}
>
{body}
</div>
{children}
</div>
)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Button } from '@/components/ui/Button'
import { cn } from '@/lib/cn'
import type { ChangeSet, UndoOutcome, useUndo } from '@/lib/history'
import type { UndoOutcome, UndoTarget, useUndo } from '@/lib/history'
/**
* Undo, plus what happened. Paired with useUndo so the history list and the
@@ -14,7 +14,7 @@ export function UndoButton({
className,
label = 'Undo',
}: {
changeSet: ChangeSet
changeSet: UndoTarget
undo: ReturnType<typeof useUndo>
className?: string
label?: string
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { describeStep } from './agent'
describe('describeStep', () => {
// Raw tool names tell you the agent is busy; these tell you what it's busy
// DOING, which is the difference between the panel feeling alive and hung.
it("names tools in the app's own vocabulary", () => {
expect(describeStep({ index: 0, tools: ['clear_object'] })).toBe('Clearing a bed')
expect(describeStep({ index: 1, tools: ['find_plant'] })).toBe('Looking up a plant')
})
it('collapses repeats within one step', () => {
expect(describeStep({ index: 0, tools: ['fill_region', 'fill_region'] })).toBe('Filling a bed')
})
it('joins distinct tools', () => {
expect(describeStep({ index: 0, tools: ['clear_object', 'fill_region'] })).toBe('Clearing a bed, Filling a bed')
})
it('says something for a step with no tool calls', () => {
expect(describeStep({ index: 0, tools: [] })).toBe('Thinking')
})
// A tool added server-side before the client knows about it should degrade to
// something readable rather than showing snake_case at the user.
it('falls back readably for an unknown tool', () => {
expect(describeStep({ index: 0, tools: ['prune_orchard'] })).toBe('prune orchard')
})
})
+232
View File
@@ -0,0 +1,232 @@
// The garden assistant's client (#57).
//
// The chat lives in the editor, not on its own page, because watching the canvas
// change as the agent works IS the confirmation — which is what makes acting
// without asking first tolerable. So this module's job is as much about
// surfacing progress as it is about sending a message.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { API_BASE, api } from './api'
import { gardenFullKey } from './objects'
import { historyKey } from './history'
const capabilitiesSchema = z.object({ agent: z.boolean() })
/** Whether this instance has the assistant configured. Without it the panel
* isn't rendered at all — a dead button is worse than no button. */
export function useCapabilities() {
return useQuery({
queryKey: ['capabilities'] as const,
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
staleTime: Infinity, // server config; it doesn't change under a running page
})
}
export const agentMessageSchema = z.object({
id: z.number(),
conversationId: z.number(),
role: z.enum(['user', 'assistant']),
body: z.string(),
changeSetId: z.number().optional(),
createdAt: z.string(),
})
export type AgentMessage = z.infer<typeof agentMessageSchema>
const historySchema = z.object({ messages: z.array(agentMessageSchema) })
export function agentHistoryKey(gardenId: number) {
return ['gardens', gardenId, 'agent-history'] as const
}
/** The stored conversation, so a reload doesn't lose the thread — which is
* exactly when someone reloads, to check whether a change actually landed. */
export function useAgentHistory(gardenId: number, enabled: boolean) {
return useQuery({
queryKey: agentHistoryKey(gardenId),
enabled,
queryFn: async (): Promise<AgentMessage[]> =>
historySchema.parse(await api.get(`/gardens/${gardenId}/agent/history`)).messages,
})
}
export function useClearAgentHistory(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (): Promise<void> => {
await api.delete(`/gardens/${gardenId}/agent/history`)
},
onSuccess: () => qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) }),
})
}
/** One completed turn, as the server reports it. */
export interface AgentTurn {
reply: string
changeSetId?: number
steps: number
truncated?: boolean
}
/** A step the model just finished, named in the app's own vocabulary. */
export interface AgentStep {
index: number
tools: string[]
}
// What each tool is doing, in words. Raw tool names ("fill_region") tell you the
// agent is busy; these tell you what it's busy DOING, which is the difference
// between the panel feeling alive and feeling hung.
const TOOL_LABELS: Record<string, string> = {
list_gardens: 'Looking at your gardens',
describe_garden: 'Reading the garden',
create_object: 'Adding a bed',
move_object: 'Moving a bed',
place_planting: 'Planting',
fill_region: 'Filling a bed',
clear_object: 'Clearing a bed',
find_plant: 'Looking up a plant',
create_plant: 'Adding a plant to your catalog',
add_journal_entry: 'Writing a journal note',
}
export function describeStep(step: AgentStep): string {
if (step.tools.length === 0) return 'Thinking'
const labels = step.tools.map((t) => TOOL_LABELS[t] ?? t.replace(/_/g, ' '))
// Repeated tools in one step read as one action, not a list of identical ones.
return [...new Set(labels)].join(', ')
}
const chatEventSchema = z.object({
step: z.object({ index: z.number(), tools: z.array(z.string()) }).optional(),
done: z
.object({
reply: z.string(),
changeSetId: z.number().optional(),
steps: z.number(),
truncated: z.boolean().optional(),
})
.optional(),
error: z.string().optional(),
// The turn worked but something adjacent to it didn't — currently, the
// exchange couldn't be saved. Dropping this on the floor would recreate
// exactly the silent swallow the server added it to avoid.
warning: z.string().optional(),
})
export interface StreamHandlers {
onStep: (step: AgentStep) => void
onDone: (turn: AgentTurn) => void
onError: (message: string) => void
/** The turn succeeded, but something alongside it didn't. */
onWarning?: (message: string) => void
}
/**
* Send a message and stream the reply.
*
* Hand-rolled rather than EventSource, which can only issue GETs — this needs a
* POST body. The wire format is still SSE so a proxy that understands it doesn't
* buffer, and so switching to EventSource later wouldn't change the server.
*/
export async function streamChat(
gardenId: number,
message: string,
handlers: StreamHandlers,
signal?: AbortSignal,
): Promise<void> {
let res: Response
try {
res = await fetch(`${API_BASE}/agent/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ gardenId, message }),
credentials: 'same-origin',
signal,
})
} catch {
// An abort here is the caller's own doing — Stop, or navigating away — not a
// failure to report back to them. The read loop below already knew this; the
// request path did not.
if (signal?.aborted) return
handlers.onError('Could not reach the server.')
return
}
if (!res.ok || !res.body) {
handlers.onError(
res.status === 404
? "This instance doesn't have the assistant configured."
: 'The assistant is not available right now.',
)
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
let chunk: ReadableStreamReadResult<Uint8Array>
try {
chunk = await reader.read()
} catch {
// An aborted read is the caller navigating away, not a failure worth
// reporting back to them.
if (signal?.aborted) return
handlers.onError('The connection dropped partway through.')
return
}
if (chunk.done) break
buffer += decoder.decode(chunk.value, { stream: true })
// SSE frames are separated by a blank line; anything after the last one is
// a partial frame to keep for the next chunk.
const frames = buffer.split('\n\n')
buffer = frames.pop() ?? ''
for (const frame of frames) {
const line = frame.split('\n').find((l) => l.startsWith('data:'))
if (!line) continue
// Parsed AND validated: a malformed or unexpected frame shouldn't kill a
// working stream, and shouldn't be trusted into the UI either.
const parsed = chatEventSchema.safeParse(safeJson(line.slice(5).trim()))
if (!parsed.success) continue
const e = parsed.data
if (e.warning) handlers.onWarning?.(e.warning)
if (e.error) handlers.onError(e.error)
else if (e.step) handlers.onStep(e.step)
else if (e.done) handlers.onDone(e.done)
}
}
}
function safeJson(raw: string): unknown {
try {
return JSON.parse(raw)
} catch {
return null
}
}
/**
* Refreshes for the two moments that need different amounts of work.
*
* Mid-turn, only the canvas can have changed: the change set isn't written until
* the turn commits, and the exchange isn't stored until it finishes. Refetching
* those on every step would be up to 2×(N1) requests per turn for data that
* cannot have moved.
*/
export function useAgentRefresh(gardenId: number) {
const qc = useQueryClient()
const canvas = () => {
void qc.invalidateQueries({ queryKey: gardenFullKey(gardenId) })
}
return {
/** After a step: the garden may have changed under the conversation. */
canvas,
/** After a turn: the change set and the stored exchange exist now too. */
everything: () => {
canvas()
void qc.invalidateQueries({ queryKey: historyKey(gardenId) })
void qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) })
},
}
}
+26
View File
@@ -113,3 +113,29 @@ describe('totalChanges', () => {
).toBe(13)
})
})
describe('describeUndo with an unknown total', () => {
// The chat panel offers Undo on a turn knowing only its change set id — the
// agent's reply carries the id, not the tally. Inventing a denominator to
// fill the sentence would be worse than not having one.
it("doesn't invent a denominator it was never given", () => {
const out = describeUndo(
{ id: 5 },
{
changeSet: changeSet({ id: 6, counts: [{ entityType: 'planting', op: 'delete', n: 2 }] }),
conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }],
},
)
expect(out.tone).toBe('partial')
expect(out.message).toBe('Partly undone — “North Bed” was edited since, so it was left alone.')
expect(out.message).not.toMatch(/\d+ of \d+/)
})
it('still reports a clean undo the same way', () => {
const out = describeUndo(
{ id: 5 },
{ changeSet: changeSet({ id: 6, counts: [{ entityType: 'object', op: 'update', n: 1 }] }), conflicts: [] },
)
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
})
})
+23 -6
View File
@@ -113,9 +113,10 @@ export function useRevertChangeSet(gardenId: number) {
})
}
/** How many rows a change set touched, for "3 changes" in the list. */
export function totalChanges(cs: ChangeSet): number {
return cs.counts.reduce((sum, c) => sum + c.n, 0)
/** How many rows a change set touched, for "3 changes" in the list. Undefined
* counts total zero, which callers read as "no denominator to quote". */
export function totalChanges(cs: { counts?: ChangeCount[] }): number {
return (cs.counts ?? []).reduce((sum, c) => sum + c.n, 0)
}
const ENTITY_NOUNS: Record<ChangeCount['entityType'], [string, string]> = {
@@ -171,7 +172,7 @@ export function useUndo(gardenId: number) {
const revert = useRevertChangeSet(gardenId)
const [outcomes, setOutcomes] = useState<Record<number, UndoOutcome>>({})
const undo = (cs: ChangeSet) => {
const undo = (cs: UndoTarget) => {
setOutcomes((prev) => ({ ...prev, [cs.id]: { tone: 'pending', message: 'Undoing…' } }))
revert.mutate(cs.id, {
onSuccess: (result) => {
@@ -197,6 +198,19 @@ export function useUndo(gardenId: number) {
}
}
/**
* What undo needs to know about a change set.
*
* `counts` is optional because the chat panel offers Undo on a turn knowing only
* its change set id — the agent's reply carries the id, not the tally. Fabricating
* counts to satisfy a type would produce a confidently wrong "1 of 1 changes
* undone"; leaving them out lets describeUndo say what it actually knows.
*/
export interface UndoTarget {
id: number
counts?: ChangeCount[]
}
export interface UndoOutcome {
tone: 'pending' | 'ok' | 'partial' | 'error'
message: string
@@ -208,7 +222,7 @@ export interface UndoOutcome {
* useful thing to report: a bare failure would be a lie about the two that did
* apply, and a bare success would hide the one that didn't.
*/
export function describeUndo(target: ChangeSet, result: RevertResult): UndoOutcome {
export function describeUndo(target: UndoTarget, result: RevertResult): UndoOutcome {
const skipped = result.conflicts.map(describeConflict).join('; ')
const applied = result.changeSet ? totalChanges(result.changeSet) : 0
if (result.conflicts.length === 0) {
@@ -221,6 +235,9 @@ export function describeUndo(target: ChangeSet, result: RevertResult): UndoOutco
if (applied === 0) {
return { tone: 'error', message: `Nothing was undone — ${skipped}.` }
}
// Only claim a denominator when we have one. "2 of 3" from a caller that
// never knew the total would be a number invented to fill a sentence.
const total = totalChanges(target)
return { tone: 'partial', message: `${applied} of ${total} changes undone${skipped}.` }
const scale = total > 0 ? `${applied} of ${total} changes undone` : 'Partly undone'
return { tone: 'partial', message: `${scale}${skipped}.` }
}
+22
View File
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/Button'
import { GardenCanvas } from '@/editor/GardenCanvas'
import { EditorRail, type RailTab } from '@/editor/EditorRail'
import { HistoryPanel } from '@/editor/HistoryPanel'
import { ChatPanel } from '@/editor/ChatPanel'
import { JournalPanel } from '@/editor/JournalPanel'
import { Inspector } from '@/editor/Inspector'
import { PlopInspector } from '@/editor/PlopInspector'
@@ -30,6 +31,7 @@ import {
} from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings'
import type { Plant } from '@/lib/plants'
import { useCapabilities } from '@/lib/agent'
import { useJournalCounts } from '@/lib/journal'
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
import { useSeedTray } from '@/lib/seedTray'
@@ -64,6 +66,7 @@ export function GardenEditorPage() {
const journalObjectId = useEditorStore((s) => s.journalObjectId)
const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId)
const journalCounts = useJournalCounts(gid)
const capabilities = useCapabilities()
const journalTotal = useMemo(
() => [...(journalCounts.data?.values() ?? [])].reduce((a, b) => a + b, 0),
[journalCounts.data],
@@ -385,6 +388,16 @@ export function GardenEditorPage() {
},
]
// Only when the instance actually has the assistant configured. A tab that
// opens onto an apology is worse than no tab.
if (capabilities.data?.agent) {
railTabs.push({
id: 'chat',
label: 'Assistant',
render: () => <ChatPanel gardenId={gid} canEdit={canEdit} />,
})
}
return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
<div className="shrink-0 md:w-40">
@@ -422,6 +435,15 @@ export function GardenEditorPage() {
>
History
</Button>
{capabilities.data?.agent && (
<Button
variant="ghost"
className="mt-1 w-full text-sm"
onClick={() => setRailTab(railTab === 'chat' ? null : 'chat')}
>
💬 Assistant
</Button>
)}
</div>
<div className="relative flex min-h-0 flex-1 flex-col gap-2">