Address Gadfly review on the chat panel
Build image / build-and-push (push) Successful in 9s

The worst finding is one I created two commits ago: the server's `warning` field
— added precisely because a failed save was being swallowed — was never read by
the client. The "I couldn't save this exchange" message went nowhere, which
recreated the exact silence the warning existed to break. It's wired now, and
SSE frames are validated with zod rather than type-asserted, so the next field
added server-side fails loudly instead of vanishing.

Aborting during the initial fetch reported "Could not reach the server." The
read loop already knew an abort was the caller's own doing; the request path did
not, so pressing Stop looked like the network had failed.

Unmount no longer aborts an in-flight turn, and that one is a design bug of my
own making: selecting an object auto-switches the rail to the inspector, so
clicking the canvas mid-turn silently killed the turn — while the canvas is
exactly what you're meant to be watching. The request continues, the exchange is
persisted server-side, and coming back to the tab shows it. Stop still aborts,
but only our READ: the turn keeps running server-side either way, which is why
its work still lands in History.

/capabilities reported cfg.Agent.Ready() while the routes require NewRunner to
have succeeded. A configured-but-unresolvable model would therefore advertise a
chat tab whose first message 404s. It reports the runner's actual state now, and
is a named handler like everything else in that file.

A failed history load rendered as an empty conversation, which looks like the
thread was lost — a much worse thing to believe than "it didn't load". Both the
loading and error states are shown, and a failed "Start over" says so.

Per-step refresh refetched the change history and the conversation, neither of
which can move until the turn commits: up to 2×(N−1) requests per turn for data
that cannot have changed. Split into a canvas-only refresh for steps and a full
one for the end.

describeUndo had its own copy of totalChanges' summation; it uses the helper,
which now accepts an optional counts list. React.ReactNode became an imported
ReactNode, matching every other component.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-21 02:42:47 -04:00
co-authored by Claude Opus 4.8
parent 5a3fc20fc7
commit 77bc672c19
4 changed files with 108 additions and 34 deletions
+14 -6
View File
@@ -53,12 +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. 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()})
})
// 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
@@ -197,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})
+33 -12
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
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'
@@ -33,12 +34,17 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
// 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)
// Abort an in-flight turn when the panel goes away, so a closed tab doesn't
// leave a read hanging.
useEffect(() => () => abort.current?.abort(), [])
// 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(() => {
@@ -50,6 +56,7 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
if (!message || pending) return
setInput('')
setError(null)
setWarning(null)
setPending({ message, steps: [] })
const controller = new AbortController()
@@ -61,22 +68,24 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
{
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()
// 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()
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()
refresh.everything()
},
},
controller.signal,
@@ -92,7 +101,11 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
{messages.length > 0 && (
<button
type="button"
onClick={() => clear.mutate()}
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"
>
@@ -108,6 +121,13 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
)}
<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
@@ -146,6 +166,7 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
</>
)}
{warning && <Alert tone="info">{warning}</Alert>}
{error && <Alert>{error}</Alert>}
<div ref={bottom} />
</div>
@@ -177,7 +198,7 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
onClick={() => {
abort.current?.abort()
setPending(null)
refresh()
refresh.everything()
}}
>
Stop
@@ -199,7 +220,7 @@ function Bubble({
}: {
role: 'user' | 'assistant'
body: string
children?: React.ReactNode
children?: ReactNode
}) {
const mine = role === 'user'
return (
+56 -12
View File
@@ -97,10 +97,29 @@ export function describeStep(step: AgentStep): string {
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
}
/**
@@ -126,6 +145,10 @@ export async function streamChat(
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
}
@@ -162,13 +185,12 @@ export async function streamChat(
for (const frame of frames) {
const line = frame.split('\n').find((l) => l.startsWith('data:'))
if (!line) continue
let event: unknown
try {
event = JSON.parse(line.slice(5).trim())
} catch {
continue // a malformed frame shouldn't kill a working stream
}
const e = event as { step?: AgentStep; done?: AgentTurn; error?: string }
// 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)
@@ -176,13 +198,35 @@ export async function streamChat(
}
}
/** Refresh everything a turn may have changed: the canvas, the history list,
* and the conversation itself. */
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()
return () => {
const canvas = () => {
void qc.invalidateQueries({ queryKey: gardenFullKey(gardenId) })
void qc.invalidateQueries({ queryKey: historyKey(gardenId) })
void qc.invalidateQueries({ queryKey: agentHistoryKey(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) })
},
}
}
+5 -4
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]> = {
@@ -236,7 +237,7 @@ export function describeUndo(target: UndoTarget, result: RevertResult): UndoOutc
}
// 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 = target.counts ? target.counts.reduce((sum, c) => sum + c.n, 0) : 0
const total = totalChanges(target)
const scale = total > 0 ? `${applied} of ${total} changes undone` : 'Partly undone'
return { tone: 'partial', message: `${scale}${skipped}.` }
}