Author SHA1 Message Date
steveandClaude Opus 4.8 6c12dcfe2a Chat panel in the garden editor (#57)
Build image / build-and-push (push) Successful in 16s
Gadfly review (reusable) / review (pull_request) Successful in 9m56s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m56s
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 02:22:34 -04:00
16 changed files with 104 additions and 825 deletions
-45
View File
@@ -85,12 +85,6 @@ Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
deliberately. `ErrForbidden` means "you can see it but may not do that".
- **Plops (plantings) live in their parent object's local frame**, origin at the
object's center, `-y` is north. Moving or rotating a bed moves its plants free.
- **A plop is a clump, not a plant.** `defaultPlopRadius` is `1.5 × spacing`, so a
plop is three spacings across and holds `π·r²/spacing²` plants. Reasoning about
fills as if one plop were one plant gets the geometry wrong every time — which
is how #75 happened: requiring the whole circle inside the bed inset the outer
row by 1.5 spacings when the horticultural rule is *half* a spacing. Spacing is
a constraint between neighbouring plants; a bed edge is nobody's neighbour.
- **Soft removal**: "clear bed" sets `removed_at`; the editor reads
`removed_at IS NULL`. Hard delete is a different operation.
- **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run
@@ -98,29 +92,6 @@ Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
- **Every service mutation lands in history** (#48). If you add one, record it —
see `internal/service/revisions.go`. Multi-row operations pass all their
changes to a single `record` call so they undo as one unit.
- **The history write is detached from cancellation on purpose.** `commitScope`
calls `context.WithoutCancel` — that is not a mistake to tidy up. By the time
a commit runs, the rows it describes are already written, so cancelling it
cannot undo anything; it can only leave real changes with no way to undo them.
This was a live bug twice (#73): a client disconnect mid-request orphaned 18
plantings. Fixing it per-call-site is how it came back, which is why the rule
lives in `commitScope` where no caller can forget it.
## Testing
Match the test to the failure it would catch:
- **Anything addressed by its own id needs an API-level test through the router.**
Service tests can't see a route that was never registered — PATCH/DELETE
`/journal/:id` once shipped fully implemented, fully unit-tested, and
completely unreachable.
- **Watch for fixtures that assert your assumptions instead of the API.** A test
for the undo message passed because the fixture I wrote populated a field the
real response leaves empty. If a test builds the thing it's testing against,
it is checking your mental model, not the system.
- Some things only real use finds. The agent's whole loop is covered by
majordomo's scriptable fake provider (`provider/fake`), which is worth using —
but the three worst v2 bugs all turned up in one live session afterwards.
## Workflow
@@ -129,22 +100,6 @@ fix what's real → merge when the pipeline is green. Do not grade Gadfly findin
A push to `main` builds the image and deploys to Komodo; the live instance at
`pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later.
**Gadfly reviews the PR as opened, not as merged.** The workflow triggers on
`opened`/`reopened`/`ready_for_review` — deliberately *not* `synchronize` — so
every commit you push afterwards, including the ones you push in response to
Gadfly itself, is unreviewed unless you ask. Once you've stopped pushing and
before you merge, comment **`@gadfly review`** on the PR to re-trigger it. The
phrase is required, and this is not hypothetical: on #76 the follow-up commit
was the one that contained a real bug.
**A skipped Gadfly run reports success.** A comment without the trigger phrase
still starts the workflow, which logs `comment does not contain trigger phrase`
and exits green in ~2 seconds. So "the pipeline is green" does NOT mean "this
was reviewed". Confirm a re-review actually ran by its **duration** — a real
pass takes ~10 minutes, a skip takes 2 seconds. Don't look for a new consensus
comment: Gadfly EDITS its existing status-board and consensus comments in place,
so their `created_at` stays at the first review and only `updated_at` moves.
Workflow- and config-only changes (CI, this file, docs) go straight to `main`
without the PR dance.
-1
View File
@@ -7,7 +7,6 @@ Work is tracked in Gitea issues; the tracking epic links every piece in dependen
## Decisions
- **Placement model:** freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle.
- **Spacing is a plant-to-plant rule, so bed edges get half of it.** A bed edge is not a competitor for soil, light or water, so the outer row owes it half the spacing rather than a full one. `FillRegion` centres its lattice accordingly, and lets a plop — a *clump* three spacings across — cross the edge by up to half a spacing so its outermost plants land at that half-spacing. The rule, the square-foot-chart arithmetic behind it, and the failure mode it prevents are written out once in `hexCenters`; #75 is what getting it wrong looked like.
- **Stack:** Go 1.26.x backend, module `gitea.stevedudenhoeffer.com/steve/pansy`; React + TypeScript + Vite + Tailwind frontend, production build embedded via `embed.FS` → one static binary (`CGO_ENABLED=0`).
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes.
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
+1 -4
View File
@@ -73,8 +73,6 @@ 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`).
@@ -113,8 +111,7 @@ services:
# PANSY_OIDC_ISSUER: https://auth.example.com/application/o/pansy/
# PANSY_OIDC_CLIENT_ID: ...
# PANSY_OIDC_CLIENT_SECRET: ...
# OLLAMA_CLOUD_API_KEY: ${OLLAMA_CLOUD_API_KEY} # enables the garden assistant
# PANSY_AGENT_MODEL: ollama-cloud/glm-5.2:cloud
# OLLAMA_CLOUD_API_KEY: ... # enables the garden assistant
restart: unless-stopped
volumes:
pansy-data:
+6 -70
View File
@@ -8,8 +8,6 @@ import (
"io"
"log/slog"
"net/http"
"sync"
"time"
mdagent "gitea.stevedudenhoeffer.com/steve/majordomo/agent"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
@@ -26,11 +24,6 @@ import (
// by everything at once, which reads as a hang — and the whole design rests on
// watching the canvas change as it happens.
// keepAliveInterval is how often a quiet stream emits a comment frame. Well
// under the 3060s idle timeout typical of reverse proxies, which is the thing
// it exists to stay ahead of.
const keepAliveInterval = 20 * time.Second
// chatRequest is the body of POST /agent/chat.
type chatRequest struct {
GardenID int64 `json:"gardenId" binding:"required"`
@@ -69,21 +62,13 @@ func (h *handlers) agentChat(c *gin.Context) {
return
}
stream := openEventStream(c)
send := stream.send
// A model thinking hard between tool calls sends nothing for a while, and an
// idle proxy will cut a quiet connection. Deferred so a panic in the run
// can't leak the ticker goroutine; stopping it twice is harmless.
stopBeat := stream.keepAlive(keepAliveInterval)
defer stopBeat()
send := openEventStream(c)
turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
replayHistory(history),
func(s mdagent.Step) {
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
})
stopBeat()
if err != nil {
// The stream is already open, so an error is an event rather than a
// status code — the client has committed to reading a stream by now.
@@ -108,74 +93,25 @@ func (h *handlers) agentChat(c *gin.Context) {
send(chatEvent{Done: turn})
}
// eventStream serializes writes to one SSE response.
//
// The mutex is load-bearing, not decoration: step events are sent from the
// agent's run goroutine while the keep-alive ticker writes from its own, and two
// goroutines writing a ResponseWriter concurrently is a data race that corrupts
// frames long before it crashes anything.
type eventStream struct {
c *gin.Context
mu sync.Mutex
}
// openEventStream puts the response into SSE mode.
// openEventStream puts the response into SSE mode and returns a sender.
//
// Headers go out before the first write and the stream is flushed immediately,
// so a proxy holding the response until it looks complete can't reintroduce
// exactly the silence streaming exists to remove.
func openEventStream(c *gin.Context) *eventStream {
func openEventStream(c *gin.Context) func(chatEvent) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no")
c.Writer.Flush()
return &eventStream{c: c}
}
func (s *eventStream) send(ev chatEvent) {
return func(ev chatEvent) {
b, err := json.Marshal(ev)
if err != nil {
slog.Error("api: encode chat event", "error", err)
return
}
s.write(fmt.Sprintf("data: %s\n\n", b))
}
func (s *eventStream) write(frame string) {
s.mu.Lock()
defer s.mu.Unlock()
_, _ = io.WriteString(s.c.Writer, frame)
s.c.Writer.Flush()
}
// keepAlive writes an SSE comment frame on an interval until the returned
// function is called, so a long silence while the model thinks doesn't look like
// a dead connection to whatever sits in between. SSE ignores comment frames, so
// this costs the client nothing.
func (s *eventStream) keepAlive(every time.Duration) func() {
done := make(chan struct{})
stopped := make(chan struct{})
go func() {
defer close(stopped)
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-done:
return
case <-s.c.Request.Context().Done():
return
case <-t.C:
s.write(": keep-alive\n\n")
}
}
}()
// Idempotent: the handler stops it explicitly when the run returns and again
// via defer, so a panic can't leak the goroutine.
var once sync.Once
return func() {
once.Do(func() { close(done) })
<-stopped
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", b)
c.Writer.Flush()
}
}
+6 -25
View File
@@ -53,10 +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.
// 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)
// 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
@@ -129,17 +131,6 @@ 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 {
@@ -195,16 +186,6 @@ 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})
+32 -132
View File
@@ -28,9 +28,13 @@ type Region struct {
MinX, MinY, MaxX, MaxY float64
}
// contains reports whether a local point lies in the region.
func (r Region) contains(x, y float64) bool {
return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY
}
// clampTo intersects the region with an object's local bounds (±halfW, ±halfH),
// so a fill can't plant outside the object it was aimed at. A region that misses
// the object entirely comes back empty — see empty().
// so an oversized caller-supplied region can't make hexCenters loop forever.
func (r Region) clampTo(halfW, halfH float64) Region {
return Region{
MinX: math.Max(r.MinX, -halfW), MinY: math.Max(r.MinY, -halfH),
@@ -38,16 +42,6 @@ func (r Region) clampTo(halfW, halfH float64) Region {
}
}
// empty reports whether the region encloses nothing.
//
// This exists because clampTo expresses "no overlap" by INVERTING the region —
// Max clamps below Min — rather than by zeroing it, which is not something a
// reader guesses. Naming it once here beats a bare `MaxX < MinX` at each place
// that has to care.
func (r Region) empty() bool {
return r.MaxX < r.MinX || r.MaxY < r.MinY
}
// rect builds a rectangular region.
func rect(minX, minY, maxX, maxY float64) Region {
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
@@ -100,11 +94,9 @@ func defaultPlopRadius(spacingCM float64) float64 {
// FillRegion lays a hex-packed field of plops of one plant across a region of a
// plantable object the actor can edit. Plop radius comes from the plant's spacing
// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2×
// radius pitch, centered in the region, and set in from each edge by the plop's
// radius less half a spacing — see hexCenters for why that half-spacing is what
// the edge is owed. A candidate is skipped when its plop would sit entirely
// inside an existing active plop (so re-filling doesn't stack duplicates).
// Returns the plops it created.
// radius pitch, kept where the center is inside the region. A candidate is
// skipped when its plop would sit entirely inside an existing active plop (so
// re-filling doesn't stack duplicates). Returns the plops it created.
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil {
@@ -114,9 +106,9 @@ func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, regio
}
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
// already loaded and authorized (roleEditor). It rejects a non-finite region,
// clamps the region to the object's bounds, refuses fills over maxFillPlops, and
// inserts the whole batch in one transaction rather than one round-trip per plop.
// already loaded and authorized (roleEditor). It clamps the region to the
// object's bounds, refuses fills over maxFillPlops, and inserts the whole batch
// in one transaction rather than one round-trip per plop.
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
if !o.Plantable {
return nil, domain.ErrInvalidInput
@@ -137,21 +129,9 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
return nil, domain.ErrInvalidInput
}
// A caller-supplied region is arbitrary floats, and non-finite ones survive
// everything downstream: clamping keeps them, the inverted-region guard can't
// see NaN (it compares false both ways), and fitAxis centres on them happily.
// Nothing corrupt reaches the table — SQLite stores NaN as NULL and the NOT
// NULL constraint refuses it — but the caller gets an opaque store error for
// NaN, and for +Inf a silent zero-plop success. Both are lies about what went
// wrong; say "bad input" here instead.
if !isFinite(region.MinX) || !isFinite(region.MinY) ||
!isFinite(region.MaxX) || !isFinite(region.MaxY) {
return nil, domain.ErrInvalidInput
}
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
centers, total := hexCenters(region, radius, spacing, maxFillPlops)
if total > maxFillPlops {
centers := hexCenters(region, radius)
if len(centers) > maxFillPlops {
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
}
@@ -189,112 +169,32 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
type localPoint struct{ x, y float64 }
// hexCenters returns hex-packed lattice centers filling a region: rows radius·√3
// apart, alternate rows offset by half a pitch, at a 2×radius pitch. The lattice
// is CENTERED, so the leftover is shared between opposite edges instead of piling
// up against the far one.
//
// # How close to the edge the outer row goes
//
// Spacing is a constraint BETWEEN NEIGHBOURING PLANTS competing for the same
// soil, light and water. A bed edge is not a competitor, so the outer row only
// owes it HALF the spacing — the half it would otherwise share with a neighbour.
// That is the arithmetic inside every square-foot-gardening chart: 4 per square
// is 6" apart and 3" from the square's edge; 9 per square is 4" apart and 2"
// from the edge. Garlic at 9 per square goes in 2" from the frame, not 6".
//
// A plop is a CLUMP, not a plant — defaultPlopRadius makes it 1.5×spacing, so
// three spacings across — and its plants sit out to its rim. So keeping the whole
// circle inside the bed would inset the outer row by a full 1.5 spacings, three
// times what the rule allows. Instead the clump may hang over the edge by up to
// half a spacing, which puts its outermost plants exactly the half-spacing from
// the edge that the rule asks for. Overhang is capped there and nowhere near the
// full radius: a clump mostly outside the bed is a drawing of plants in the path.
//
// Do not "simplify" this back to anchoring at the region's min corner. That is
// what #75 was: staggered rows start a full pitch in, and the leftover all lands
// on the far edge, where clumps hang outside a bed that nothing clips them to.
//
// # Counting before building
//
// hexCenters returns the total alongside the points, and works that total out
// BEFORE building anything: a fill large enough to be refused shouldn't allocate
// its whole lattice first just to be counted and thrown away. Over `limit` it
// returns (nil, total), so the caller can still refuse with the real number.
func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int) {
// hexCenters returns hex-packed lattice centers whose center lies in the region.
// Rows are spaced radius·√3 apart and every other row is offset by radius, the
// standard hexagonal packing at a 2×radius pitch. The lattice is anchored one
// radius inside the region's min corner so the first plop sits inside it.
func hexCenters(r Region, radius float64) []localPoint {
if radius <= 0 {
return nil, 0
}
// An empty region has no inside to plant. The old loop-until-past-MaxX form
// got this for free by never entering the loop; counting positions up front
// does not, and would site a plop off the bed.
if r.empty() {
return nil, 0
return nil
}
pitch := 2 * radius
rowH := pitch * math.Sqrt(3) / 2
// How far a clump's centre must stay inside the edge: its own radius, less the
// half-spacing of overhang the rule allows. Never negative, and never past the
// centre of the clump.
inset := math.Max(0, radius-math.Max(0, spacing)/2)
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
// Exact, not an upper bound: staggered rows hold one fewer, so rows*cols would
// over-reserve by ~12% — and, more to the point, allocating it is the thing we
// are trying to avoid when the answer is "too many".
staggered := cols
if cols > 1 {
staggered = cols - 1
const eps = 1e-6
var pts []localPoint
row := 0
for y := r.MinY + radius; y <= r.MaxY+eps; y += rowH {
xStart := r.MinX + radius
if row%2 == 1 {
xStart += radius
}
total := (rows+1)/2*cols + rows/2*staggered
if total > limit {
return nil, total
}
pts := make([]localPoint, 0, total)
for row := 0; row < rows; row++ {
y := r.MinY + y0 + float64(row)*rowH
n, x := cols, r.MinX+x0
// The stagger falls out of centering: an offset row holds one fewer plop,
// and centering THAT run puts it exactly half a pitch off its neighbours.
// A single-column region has nothing to stagger against.
if row%2 == 1 && cols > 1 {
n, x = staggered, r.MinX+x0+pitch/2
}
for i := 0; i < n; i++ {
pts = append(pts, localPoint{x + float64(i)*pitch, y})
for x := xStart; x <= r.MaxX+eps; x += pitch {
if r.contains(x, y) {
pts = append(pts, localPoint{x, y})
}
}
return pts, total
}
// fitAxis returns how many lattice positions fit along a span at `step`, keeping
// at least `inset` from each end, and the offset from the span's start that
// centers them — so the leftover is split between the two edges rather than all
// landing on the far one.
//
// A span too small to hold even one position at that inset still gets one, in the
// middle: filling a bed narrower than a single plop with one plop is a better
// answer than refusing to plant it.
//
// The step<=0 half of that guard is currently unreachable — hexCenters, the only
// caller, returns early unless radius > 0, which makes both steps it passes
// positive. It stays because dividing by a non-positive step yields ±Inf and then
// a garbage int conversion, and a helper this small should not require reading
// its caller to know it is safe. Deliberate, not an oversight.
func fitAxis(length, step, inset float64) (n int, start float64) {
if step <= 0 || length < 2*inset {
return 1, length / 2
row++
}
// The epsilon keeps an exact fit from being lost to floating point — a 60cm
// span at a 30cm step should give 2 positions, not 1 because the division
// landed on 0.9999999.
const eps = 1e-9
n = int(math.Floor((length-2*inset)/step+eps)) + 1
return n, (length - float64(n-1)*step) / 2
return pts
}
// coveredByExisting reports whether a new plop (center, radius) would sit
+5 -174
View File
@@ -3,8 +3,6 @@ package service
import (
"context"
"errors"
"math"
"sort"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
@@ -73,165 +71,6 @@ func TestDefaultPlopRadius(t *testing.T) {
}
}
// TestHexCentersEdgeInset pins the spacing rule the packing exists to honour:
// spacing is a constraint between neighbouring plants, so a bed edge — which is
// nobody's neighbour — is owed half a pitch, not a whole one.
//
// The bug this guards against was visible to anyone who filled a bed: staggered
// rows began a full pitch in, leaving a bare strip a whole plop wide down one
// side of every other row, while the far edge had plops hanging off it.
func TestHexCentersEdgeInset(t *testing.T) {
for _, tc := range []struct {
name string
w, h, radius, spacing float64
wantRowStarts []float64 // x of the first plop in rows 0 and 1
}{
// 4ft × 8ft bed, garlic at 15cm spacing → radius 22.5, pitch 45. Three
// columns, the outer ones overhanging by 6.5cm — under the 7.5cm the rule
// allows. Anchored at the corner this row started at -38.5 and its
// staggered neighbour a full 45 further in still.
{"4ft bed of garlic", 122, 244, 22.5, 15, []float64{-45, -22.5}},
// An exact fit: 90 wide at pitch 30 → 3 columns, no overhang needed.
{"exact fit", 90, 90, 15, 10, []float64{-30, -15}},
} {
t.Run(tc.name, func(t *testing.T) {
r := rect(-tc.w/2, -tc.h/2, tc.w/2, tc.h/2)
pts, total := hexCenters(r, tc.radius, tc.spacing, maxFillPlops)
if len(pts) == 0 {
t.Fatal("no centers")
}
// The count is derived up front so an oversized fill is refused without
// building its lattice — which only works if it matches what gets built.
if total != len(pts) {
t.Errorf("reported total %d, built %d", total, len(pts))
}
// A clump may cross the edge, but only by the half-spacing the rule
// allows — never enough to be mostly out in the path.
budget := tc.spacing / 2
for _, p := range pts {
over := math.Max(
math.Max(r.MinX-(p.x-tc.radius), (p.x+tc.radius)-r.MaxX),
math.Max(r.MinY-(p.y-tc.radius), (p.y+tc.radius)-r.MaxY),
)
if over > budget+1e-6 {
t.Errorf("plop at (%.1f,%.1f) overhangs by %.2f, budget %.2f", p.x, p.y, over, budget)
}
}
// The margins match on opposite edges: the leftover is shared, not piled
// against the far side.
minX, maxX, minY, maxY := pts[0].x, pts[0].x, pts[0].y, pts[0].y
for _, p := range pts {
minX, maxX = math.Min(minX, p.x), math.Max(maxX, p.x)
minY, maxY = math.Min(minY, p.y), math.Max(maxY, p.y)
}
if w, e := minX-r.MinX, r.MaxX-maxX; math.Abs(w-e) > 1e-6 {
t.Errorf("lopsided horizontally: west margin %.2f, east %.2f", w, e)
}
if n, s := minY-r.MinY, r.MaxY-maxY; math.Abs(n-s) > 1e-6 {
t.Errorf("lopsided vertically: north margin %.2f, south %.2f", n, s)
}
// The staggered row is offset by HALF a pitch, not a whole one.
starts := map[float64]float64{}
for _, p := range pts {
if x, ok := starts[p.y]; !ok || p.x < x {
starts[p.y] = p.x
}
}
ys := make([]float64, 0, len(starts))
for y := range starts {
ys = append(ys, y)
}
sort.Float64s(ys)
for i, want := range tc.wantRowStarts {
if i >= len(ys) {
t.Fatalf("only %d rows, want at least %d", len(ys), len(tc.wantRowStarts))
}
if got := starts[ys[i]]; math.Abs(got-want) > 1e-6 {
t.Errorf("row %d starts at x=%.2f, want %.2f", i, got, want)
}
}
})
}
}
// TestHexCentersTinyRegion covers a region too small to hold a plop at the
// half-pitch inset: planting one in the middle beats refusing to plant at all.
//
// The off-centre case earns its place — a region symmetric about the origin
// can't tell "the middle of the region" from "the origin", so on its own it
// would pass for an implementation that just returned (0,0).
func TestHexCentersTinyRegion(t *testing.T) {
for _, tc := range []struct {
name string
r Region
wantX, wantY float64
}{
{"centred on the origin", rect(-5, -5, 5, 5), 0, 0},
{"off in a corner", rect(20, -40, 30, -30), 25, -35},
} {
t.Run(tc.name, func(t *testing.T) {
pts, _ := hexCenters(tc.r, 15, 10, maxFillPlops)
if len(pts) != 1 || pts[0].x != tc.wantX || pts[0].y != tc.wantY {
t.Errorf("got %+v, want one plop at (%v,%v)", pts, tc.wantX, tc.wantY)
}
})
}
}
// TestFillRegionRejectsNonFiniteRegion: non-finite bounds survive clamping and
// the inverted-region guard (NaN compares false both ways). Without the explicit
// check, NaN surfaced as a raw store error ("NOT NULL constraint failed") and
// +Inf as a silent success that planted nothing — neither of which tells the
// caller what it actually did wrong.
func TestFillRegionRejectsNonFiniteRegion(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
bed := seedFillBed(t, s, owner, g.ID, 100, 100)
plant := seedOwnPlant(t, s, owner, 10)
nan := math.NaN()
for _, r := range []Region{
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
} {
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil)
if !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
}
for _, p := range created {
if !isFinite(p.XCM) || !isFinite(p.YCM) {
t.Errorf("persisted a plop with non-finite coordinates: %+v", p)
}
}
}
}
// TestFillRegionOutsideObjectPlantsNothing covers a region that misses the object
// entirely. clampTo inverts such a region rather than emptying it, and an
// inverted region must plant nothing — not one plop at some point off the bed.
func TestFillRegionOutsideObjectPlantsNothing(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
bed := seedFillBed(t, s, owner, g.ID, 100, 100) // local bounds ±50
plant := seedOwnPlant(t, s, owner, 10)
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50.
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil)
if err != nil {
t.Fatalf("FillRegion: %v", err)
}
if len(created) != 0 {
t.Errorf("filled %d plops for a region outside the bed, want 0: %+v", len(created), created)
}
}
// seedFillBed makes a plantable bed of the given size centered in a big garden.
func seedFillBed(t *testing.T, s *Service, owner, gardenID int64, w, h float64) *domain.GardenObject {
t.Helper()
@@ -260,24 +99,16 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
if err != nil {
t.Fatalf("FillRegion: %v", err)
}
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart, centered: a row of 2
// (x=±15), then a staggered row of 1 (x=0) → 3 plops.
//
// This was 4 while the lattice was anchored at the min corner, and the fourth
// sat at x=30 — centred ON the east edge, so half of it lay outside the bed,
// well past the half-spacing (5cm here) the rule allows. Packing one fewer
// plop is the point of the fix, not a regression in it.
if len(created) != 3 {
t.Fatalf("filled %d plops, want 3 (60×60 bed, radius 15)", len(created))
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart → 4 plops (2 rows × 2).
if len(created) != 4 {
t.Fatalf("filled %d plops, want 4 (60×60 bed, radius 15)", len(created))
}
for _, p := range created {
if p.RadiusCM != 15 || p.PlantedAt == nil || p.DerivedCount < 1 {
t.Errorf("unexpected created plop: %+v", p)
}
// This bed fits its lattice exactly, so nothing should need to overhang.
if p.XCM-p.RadiusCM < -30 || p.XCM+p.RadiusCM > 30 ||
p.YCM-p.RadiusCM < -30 || p.YCM+p.RadiusCM > 30 {
t.Errorf("plop overhangs a bed it fits inside: %+v", p)
if p.XCM < -30 || p.XCM > 30 || p.YCM < -30 || p.YCM > 30 {
t.Errorf("plop center out of bed bounds: %+v", p)
}
}
+12 -59
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"log/slog"
"sort"
"sync"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
@@ -130,7 +129,11 @@ func (s *Service) WithChangeSet(ctx context.Context, actorID, gardenID int64, op
// which is exactly the situation undo exists for. Record what happened,
// mark the summary, and still report the failure.
sc.summary = partialSummary(sc.summary)
if _, cerr := s.commitScope(ctx, sc, nil); cerr != nil {
// Detached from cancellation on purpose. The commonest reason fn failed is
// that ctx was cancelled or timed out — and using that same dead context
// to record what committed would fail too, losing the history for changes
// that really happened. That is precisely the case this path exists for.
if _, cerr := s.commitScope(context.WithoutCancel(ctx), sc, nil); cerr != nil {
slog.Error("service: partial turn could not be recorded", "error", cerr, "garden", gardenID)
}
return nil, err
@@ -150,19 +153,11 @@ func partialSummary(summary string) string {
// commitScope writes a scope's buffered revisions as one change set. revertsID is
// set only by RevertChangeSet. A scope with no revisions writes nothing — an
// operation that changed nothing doesn't belong in history.
//
// The write is DETACHED FROM CANCELLATION, always, and that belongs here rather
// than at each call site so no caller can be the one that forgets. By the time a
// commit runs, the data changes it describes have already been written — so
// cancelling it cannot undo anything. It can only lose the record of what
// happened and leave real changes with no way to undo them, which is the one
// thing the whole change-set design exists to prevent.
func (s *Service) commitScope(ctx context.Context, sc *changeScope, revertsID *int64) (*domain.ChangeSet, error) {
revs := sc.taken()
if len(revs) == 0 {
return nil, nil
}
ctx = context.WithoutCancel(ctx)
return s.store.WriteChangeSet(ctx, &domain.ChangeSet{
GardenID: sc.gardenID,
ActorID: sc.actorID,
@@ -203,13 +198,9 @@ func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary s
sc.append(revs)
return
}
// Auto-scope: one operation, its own change set. Written through the same
// detached path as everything else — a REST client that hangs up right after
// its PATCH landed must not leave that change without history, and this is
// the path virtually every mutation takes.
auto := &changeScope{gardenID: gardenID, actorID: actorID, source: domain.SourceUI, summary: summary}
auto.append(revs)
if _, err := s.commitScope(ctx, auto, nil); err != nil {
if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
GardenID: gardenID, ActorID: actorID, Source: domain.SourceUI, Summary: summary,
}, revs); err != nil {
slog.Error("service: record change set", "error", err, "garden", gardenID, "summary", summary)
}
}
@@ -320,9 +311,10 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
changes, conflict, err := s.applyInverse(ctx, r, applied)
if err != nil {
// Record what actually landed before surfacing the failure, so the
// partial revert is visible and undoable rather than orphaned.
// (commitScope detaches from cancellation itself.)
if _, cerr := s.commitScope(ctx, sc, &target.ID); cerr != nil {
// partial revert is visible and undoable rather than orphaned. Detached
// from cancellation for the same reason as WithChangeSet's path: a
// timed-out context can't be used to write the record of what it did.
if _, cerr := s.commitScope(context.WithoutCancel(ctx), sc, &target.ID); cerr != nil {
slog.Error("service: partial revert could not be recorded", "error", cerr, "changeSet", changeSetID)
}
return nil, nil, err
@@ -342,48 +334,9 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
if err != nil {
return nil, nil, err
}
// Fill in the per-op counts. commitScope returns the freshly inserted row,
// which carries no tally — and a caller receiving a change set with an empty
// Counts cannot tell "nothing was reverted" from "the tally wasn't loaded".
// That ambiguity is not theoretical: it made the chat panel report a
// successful undo as "nothing left to undo".
if cs != nil {
cs.Counts = countRevisions(sc.taken())
}
return cs, conflicts, nil
}
// countRevisions tallies revisions by (entity type, op).
//
// This deliberately reproduces in Go what ListChangeSets does in SQL, because a
// change set that has just been written has no rows to GROUP BY yet — the
// alternative is a second round trip to count what we are already holding.
// The parity is a real cross-layer contract, and TestRevertResultCarriesItsCounts
// compares the two breakdowns row for row so it can't drift silently.
func countRevisions(revs []domain.Revision) []domain.ChangeCount {
type key struct{ entityType, op string }
seen := map[key]int{}
order := []key{}
for _, r := range revs {
k := key{r.EntityType, r.Op}
if _, ok := seen[k]; !ok {
order = append(order, k)
}
seen[k]++
}
sort.Slice(order, func(i, j int) bool {
if order[i].entityType != order[j].entityType {
return order[i].entityType < order[j].entityType
}
return order[i].op < order[j].op
})
counts := make([]domain.ChangeCount, 0, len(order))
for _, k := range order {
counts = append(counts, domain.ChangeCount{EntityType: k.entityType, Op: k.op, N: seen[k]})
}
return counts
}
// entityKey identifies a row across the entity types a revision can name.
type entityKey struct {
entityType string
-189
View File
@@ -704,192 +704,3 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
t.Errorf("cleared %d of %d with %d revisions — all three should match", n, len(before), len(revs))
}
}
// TestRevertResultCarriesItsCounts — found by using the thing.
//
// commitScope returns the freshly inserted row, which carries no tally. A caller
// receiving a change set with empty Counts cannot tell "nothing was reverted"
// from "the tally wasn't loaded", and the chat panel read that ambiguity the
// wrong way: a successful undo reported "nothing left to undo", which is the
// worst possible thing to say about an action that just worked.
//
// It also pins the cross-layer contract that fix created. countRevisions tallies
// in Go what ListChangeSets tallies in SQL, and nothing but this test stops the
// two drifting — so it compares the FULL per-(entity, op) breakdown, not just
// the totals, which would agree even if the groupings had diverged.
func TestRevertResultCarriesItsCounts(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
t.Fatalf("fill: %v", err)
}
// A second, different kind of change, so the breakdown has more than one row
// to get wrong.
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("North Bed")}, bed.Version); err != nil {
t.Fatalf("rename: %v", err)
}
sets := history(t, s, owner, g.ID)
rename, fill := sets[0], sets[1]
undoRename, conflicts, err := s.RevertChangeSet(ctx, owner, rename.ID, domain.SourceUI)
if err != nil || len(conflicts) != 0 {
t.Fatalf("revert rename: err=%v conflicts=%+v", err, conflicts)
}
undoFill, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI)
if err != nil || len(conflicts) != 0 {
t.Fatalf("revert fill: err=%v conflicts=%+v", err, conflicts)
}
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
if undo == nil {
t.Fatal("a revert that did work returned no change set")
}
if len(undo.Counts) == 0 {
t.Fatalf("change set %d came back with no tally — an empty tally reads as 'nothing happened'", undo.ID)
}
}
// Undoing a fill deletes every plop it created, so the tallies must match.
if got, want := countsTotal(undoFill.Counts), countsTotal(fill.Counts); got != want {
t.Errorf("undo of the fill reports %d changes, want %d", got, want)
}
// The SQL tally and the Go tally must agree, row for row.
listed := history(t, s, owner, g.ID)
byID := map[int64][]domain.ChangeCount{}
for _, cs := range listed {
byID[cs.ID] = cs.Counts
}
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
fromSQL, ok := byID[undo.ID]
if !ok {
t.Fatalf("revert %d is missing from the history list", undo.ID)
}
if !sameCounts(undo.Counts, fromSQL) {
t.Errorf("change set %d: revert returned %+v, the list read says %+v — countRevisions and the SQL grouping have drifted",
undo.ID, undo.Counts, fromSQL)
}
}
}
// sameCounts compares two tallies regardless of order.
func sameCounts(a, b []domain.ChangeCount) bool {
if len(a) != len(b) {
return false
}
index := func(cs []domain.ChangeCount) map[string]int {
m := map[string]int{}
for _, c := range cs {
m[c.EntityType+"/"+c.Op] = c.N
}
return m
}
ia, ib := index(a), index(b)
for k, v := range ia {
if ib[k] != v {
return false
}
}
return true
}
// countsTotal sums a tally — the server-side twin of the client's totalChanges.
func countsTotal(counts []domain.ChangeCount) int {
n := 0
for _, c := range counts {
n += c.N
}
return n
}
// TestSucceededTurnRecordsEvenIfTheCallerWentAway is the production bug.
//
// The failure path was detached from cancellation; the SUCCESS path was not. An
// agent turn whose client disconnects can still COMPLETE — and then the success
// path committed with a dead context, the write failed, and real changes were
// left with no change set and no way to undo them. Found live with 18 plantings
// behind no history at all.
//
// Nothing about a commit needs the caller to still be there: by the time it
// runs, the data it describes has already been written.
func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
ctx, cancel := context.WithCancel(context.Background())
before := len(history(t, s, owner, g.ID))
// fn does its work and SUCCEEDS, but the caller goes away before it returns.
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
Source: domain.SourceAgent, Summary: "plant beans in the second bed",
}, func(ctx context.Context) error {
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
return err
}
cancel() // the client disconnects, mid-turn, after the work landed
return nil
})
if err != nil {
t.Fatalf("WithChangeSet: %v", err)
}
if cs == nil {
t.Fatal("the turn changed things but produced no change set")
}
after := history(t, s, owner, g.ID)
if len(after) != before+1 {
t.Fatalf("recorded %d change sets, want 1", len(after)-before)
}
if after[0].Summary != "plant beans in the second bed" {
t.Errorf("summary = %q; a completed turn shouldn't be marked partial", after[0].Summary)
}
// And it's undoable, which is the entire point.
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, cs.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("the recorded turn should be undoable: err=%v conflicts=%+v", err, conflicts)
}
active, _ := s.store.ListActivePlantingsForObject(context.Background(), bed.ID)
if len(active) != 0 {
t.Errorf("%d plantings survived the undo", len(active))
}
}
// TestAutoScopedMutationRecordsEvenIfTheCallerWentAway — the same rule for the
// path virtually every mutation takes.
//
// A plain REST PATCH auto-scopes into its own change set. If the client hangs up
// between the row landing and the change set being written, that change is
// orphaned exactly as an agent turn's was — and this path is used far more.
func TestAutoScopedMutationRecordsEvenIfTheCallerWentAway(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
before := len(history(t, s, owner, g.ID))
// The row write and the history write share this context; cancelling after
// the mutation returns is the client hanging up mid-request.
ctx, cancel := context.WithCancel(context.Background())
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
t.Fatalf("UpdateObject: %v", err)
}
cancel()
after := history(t, s, owner, g.ID)
if len(after) != before+1 {
t.Fatalf("recorded %d change sets, want 1 — the move is otherwise un-undoable", len(after)-before)
}
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, after[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("the recorded move should be undoable: err=%v conflicts=%+v", err, conflicts)
}
back, _ := s.store.GetObject(context.Background(), bed.ID)
if back.XCM != bed.XCM {
t.Errorf("undo left x at %v, want %v", back.XCM, bed.XCM)
}
}
+1 -3
View File
@@ -119,9 +119,7 @@ func (d *DB) ListChangeSets(ctx context.Context, gardenID int64, limit, offset i
return sets, nil
}
// One grouped query for the whole page rather than N per-row counts. The
// service's countRevisions mirrors this grouping for change sets it has just
// written; TestRevertResultCarriesItsCounts holds the two in step.
// One grouped query for the whole page rather than N per-row counts.
countRows, err := d.sql.QueryContext(ctx,
`SELECT change_set_id, entity_type, op, COUNT(*)
FROM revisions
+12 -33
View File
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState } 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'
@@ -34,17 +33,12 @@ 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)
// 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.
// 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(() => {
@@ -56,7 +50,6 @@ 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()
@@ -68,24 +61,22 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
{
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()
// 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.everything()
refresh()
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()
refresh()
},
},
controller.signal,
@@ -101,11 +92,7 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
{messages.length > 0 && (
<button
type="button"
onClick={() =>
clear.mutate(undefined, {
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
})
}
onClick={() => clear.mutate()}
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"
>
@@ -121,13 +108,6 @@ 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
@@ -166,7 +146,6 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
</>
)}
{warning && <Alert tone="info">{warning}</Alert>}
{error && <Alert>{error}</Alert>}
<div ref={bottom} />
</div>
@@ -198,7 +177,7 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
onClick={() => {
abort.current?.abort()
setPending(null)
refresh.everything()
refresh()
}}
>
Stop
@@ -220,7 +199,7 @@ function Bubble({
}: {
role: 'user' | 'assistant'
body: string
children?: ReactNode
children?: React.ReactNode
}) {
const mine = role === 'user'
return (
+1 -1
View File
@@ -116,7 +116,7 @@ function HistoryEntry({
<UndoButton
changeSet={changeSet}
undo={undo}
className="w-32 shrink-0 text-right"
className="w-32 shrink-0"
label={reverted ? 'Undo again' : 'Undo'}
/>
)}
+1 -5
View File
@@ -43,12 +43,8 @@ const TONE_CLASS: Record<Exclude<UndoOutcome['tone'], 'pending'>, string> = {
function OutcomeNote({ outcome }: { outcome: UndoOutcome }) {
if (outcome.tone === 'pending') return null
// No text alignment of its own: the container decides. The history list stacks
// this to the right of an entry, the chat panel puts it under a left-aligned
// message, and a hard-coded text-right made the chat's copy read against its
// own column.
return (
<p role="status" className={cn('text-xs', TONE_CLASS[outcome.tone])}>
<p role="status" className={cn('text-right text-xs', TONE_CLASS[outcome.tone])}>
{outcome.message}
</p>
)
+10 -54
View File
@@ -97,29 +97,10 @@ 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
}
/**
@@ -145,10 +126,6 @@ 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
}
@@ -185,12 +162,13 @@ export async function streamChat(
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)
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 }
if (e.error) handlers.onError(e.error)
else if (e.step) handlers.onStep(e.step)
else if (e.done) handlers.onDone(e.done)
@@ -198,35 +176,13 @@ export async function streamChat(
}
}
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.
*/
/** Refresh everything a turn may have changed: the canvas, the history list,
* and the conversation itself. */
export function useAgentRefresh(gardenId: number) {
const qc = useQueryClient()
const canvas = () => {
return () => {
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) })
},
}
}
-9
View File
@@ -49,14 +49,6 @@ describe('describeConflict', () => {
describe('describeUndo', () => {
const target = changeSet({ counts: [{ entityType: 'object', op: 'update', n: 3 }] })
// The bug this pair exists for: a real revert response carries a change set
// whose counts the server didn't populate. Reading that as "nothing happened"
// told the user a successful undo had done nothing.
it('trusts the change set, not the tally, for whether anything happened', () => {
const out = describeUndo({ id: 5 }, { changeSet: changeSet({ id: 6, counts: [] }), conflicts: [] })
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
})
it('is a plain success when nothing conflicted', () => {
const out = describeUndo(target, {
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 3 }] }),
@@ -69,7 +61,6 @@ describe('describeUndo', () => {
// already a no-op — reachable by undoing a creation whose object is gone.
// Claiming "Undone." there reports work that didn't happen.
it("doesn't claim to have undone a no-op", () => {
// A NULL change set — not an empty tally — is the server's no-op signal.
const out = describeUndo(target, { changeSet: null, conflicts: [] })
expect(out.tone).toBe('ok')
expect(out.message).toBe('Nothing left to undo — this was already reversed.')
+10 -14
View File
@@ -113,10 +113,9 @@ export function useRevertChangeSet(gardenId: number) {
})
}
/** 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)
/** 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)
}
const ENTITY_NOUNS: Record<ChangeCount['entityType'], [string, string]> = {
@@ -224,23 +223,20 @@ export interface UndoOutcome {
*/
export function describeUndo(target: UndoTarget, result: RevertResult): UndoOutcome {
const skipped = result.conflicts.map(describeConflict).join('; ')
// A NULL change set is the server's signal that nothing needed doing. An empty
// `counts` is not the same thing and must not be read as one — that conflation
// made a successful undo report "nothing left to undo", which is the worst
// possible thing to tell someone about an action that just worked.
const didSomething = result.changeSet != null
const applied = result.changeSet ? totalChanges(result.changeSet) : 0
if (result.conflicts.length === 0) {
// Reachable by undoing a creation whose object is already gone.
if (!didSomething) return { tone: 'ok', message: 'Nothing left to undo — this was already reversed.' }
// The server answers 200 with a null change set when every revision was
// already a no-op — reachable by undoing a creation whose object is gone.
// Claiming "Undone." there would be reporting work that didn't happen.
if (applied === 0) return { tone: 'ok', message: 'Nothing left to undo — this was already reversed.' }
return { tone: 'ok', message: 'Undone.' }
}
if (!didSomething) {
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)
const scale = total > 0 && applied > 0 ? `${applied} of ${total} changes undone` : 'Partly undone'
const total = target.counts ? target.counts.reduce((sum, c) => sum + c.n, 0) : 0
const scale = total > 0 ? `${applied} of ${total} changes undone` : 'Partly undone'
return { tone: 'partial', message: `${scale}${skipped}.` }
}