Files
pansy/web/src/lib/history.ts
T
steve 4ea0d0b262
Build image / build-and-push (push) Successful in 19s
Undo reported "nothing left to undo" after a successful undo (#72)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 12:25:34 +00:00

247 lines
9.3 KiB
TypeScript

// Change history and undo (#49): zod-validated shapes for /gardens/:id/history
// and /change-sets/:id/revert, plus the hooks the editor's History tab uses.
//
// A revert is a real write that can partly succeed, so its mutation is unusual:
// a 409 is not a plain failure. It carries the change set that DID apply
// alongside the entities it deliberately left alone, and the caller is expected
// to report both.
import { useState } from 'react'
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api, errorMessage } from './api'
import { gardenFullKey } from './objects'
export const changeSourceSchema = z.enum(['ui', 'agent', 'api'])
export type ChangeSource = z.infer<typeof changeSourceSchema>
export const changeCountSchema = z.object({
entityType: z.enum(['garden', 'object', 'planting']),
op: z.enum(['create', 'update', 'delete']),
// COUNT(*) can only ever be a non-negative integer; constraining it means bad
// data fails loudly rather than quietly hiding an Undo button (totalChanges
// gates it) or rendering "1.5 beds changed".
n: z.number().int().nonnegative(),
})
export type ChangeCount = z.infer<typeof changeCountSchema>
export const changeSetSchema = z.object({
id: z.number(),
gardenId: z.number(),
actorId: z.number(),
actorName: z.string().default(''),
source: changeSourceSchema,
summary: z.string(),
agentRunId: z.string().optional(),
// Set when this change set reverts another.
revertsId: z.number().optional(),
// Set when another change set reverted this one.
revertedById: z.number().optional(),
counts: z.array(changeCountSchema).default([]),
createdAt: z.string(),
})
export type ChangeSet = z.infer<typeof changeSetSchema>
export const revertConflictSchema = z.object({
entityType: z.enum(['garden', 'object', 'planting']),
entityId: z.number(),
reason: z.enum(['changed', 'missing', 'exists', 'unsupported']),
name: z.string().optional(),
})
export type RevertConflict = z.infer<typeof revertConflictSchema>
const historyPageSchema = z.object({
changeSets: z.array(changeSetSchema),
hasMore: z.boolean(),
})
const revertResultSchema = z.object({
changeSet: changeSetSchema.nullable(),
conflicts: z.array(revertConflictSchema).default([]),
})
export type RevertResult = z.infer<typeof revertResultSchema>
const PAGE_SIZE = 30
export function historyKey(gardenId: number) {
return ['gardens', gardenId, 'history'] as const
}
/** A garden's change sets, newest first, paged on demand. */
export function useGardenHistory(gardenId: number, enabled = true) {
return useInfiniteQuery({
queryKey: historyKey(gardenId),
enabled,
initialPageParam: 0,
queryFn: async ({ pageParam }) =>
historyPageSchema.parse(
await api.get(`/gardens/${gardenId}/history`, { params: { limit: PAGE_SIZE, offset: pageParam } }),
),
// Offset paging, not cursor: history is append-only at the FRONT, so a new
// entry arriving mid-scroll shifts the window by one. That's a duplicate row
// at worst, and the alternative (a cursor on created_at) buys little for a
// list you scroll a page or two of.
getNextPageParam: (last, pages) => (last.hasMore ? pages.length * PAGE_SIZE : undefined),
})
}
/** The conflicts carried by a 409 from a revert, or null if this isn't one. */
export function revertConflicts(err: unknown): RevertResult | null {
if (!(err instanceof ApiError) || err.status !== 409) return null
const parsed = revertResultSchema.safeParse(err.body)
return parsed.success ? parsed.data : null
}
/**
* Undo a change set. Invalidates the editor payload so the canvas redraws, and
* the history list so the new entry (and the "reverted" mark on its target)
* appear — on failure too, because a 409 means part of it applied.
*/
export function useRevertChangeSet(gardenId: number) {
const qc = useQueryClient()
const refresh = () => {
void qc.invalidateQueries({ queryKey: gardenFullKey(gardenId) })
void qc.invalidateQueries({ queryKey: historyKey(gardenId) })
}
return useMutation({
mutationFn: async (changeSetId: number): Promise<RevertResult> =>
revertResultSchema.parse(await api.post(`/change-sets/${changeSetId}/revert`, undefined)),
onSuccess: refresh,
onError: (err) => {
if (revertConflicts(err)) refresh() // a partial revert still changed things
},
})
}
/** 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]> = {
garden: ['garden setting', 'garden settings'],
object: ['bed', 'beds'],
planting: ['planting', 'plantings'],
}
const OP_VERBS: Record<ChangeCount['op'], string> = {
create: 'added',
update: 'changed',
delete: 'removed',
}
/**
* The change set's counts as a human phrase: "3 plantings added, 1 bed changed".
* Returns '' when there's nothing to say, so callers can skip the line entirely
* rather than render "0 changes".
*/
export function describeCounts(cs: ChangeSet): string {
return cs.counts
.filter((c) => c.n > 0)
.map((c) => {
const [one, many] = ENTITY_NOUNS[c.entityType]
return `${c.n} ${c.n === 1 ? one : many} ${OP_VERBS[c.op]}`
})
.join(', ')
}
/** What a revert left alone, phrased for the person who asked for the undo. */
export function describeConflict(c: RevertConflict): string {
const noun = c.name ? `“${c.name}”` : `that ${ENTITY_NOUNS[c.entityType][0]}`
switch (c.reason) {
case 'changed':
return `${noun} was edited since, so it was left alone`
case 'missing':
return `${noun} no longer exists`
case 'exists':
return `${noun} exists again, so it wasn't restored over the top`
case 'unsupported':
return `${noun} can't be undone automatically`
}
}
/**
* The one undo implementation, shared by the history list and (per #49) the
* agent turn's inline Undo in #57 — so an undo behaves identically wherever it
* is offered, including how it explains a partial result.
*
* Outcomes are keyed by change set id, so a single instance serves a whole list.
*/
export function useUndo(gardenId: number) {
const revert = useRevertChangeSet(gardenId)
const [outcomes, setOutcomes] = useState<Record<number, UndoOutcome>>({})
const undo = (cs: UndoTarget) => {
setOutcomes((prev) => ({ ...prev, [cs.id]: { tone: 'pending', message: 'Undoing…' } }))
revert.mutate(cs.id, {
onSuccess: (result) => {
setOutcomes((prev) => ({ ...prev, [cs.id]: describeUndo(cs, result) }))
},
onError: (err) => {
const result = revertConflicts(err)
setOutcomes((prev) => ({
...prev,
[cs.id]: result
? describeUndo(cs, result)
: { tone: 'error', message: errorMessage(err, "That couldn't be undone.") },
}))
},
})
}
return {
undo,
// Per change set rather than a single global flag, so one row's pending
// state can't disable the others.
outcomeFor: (id: number): UndoOutcome | undefined => outcomes[id],
}
}
/**
* 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
}
/**
* What actually happened, said in full. A revert can partly succeed, and "2 of 3
* changes undone — “north bed” was edited since and was left alone" is the only
* 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: 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.' }
return { tone: 'ok', message: 'Undone.' }
}
if (!didSomething) {
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'
return { tone: 'partial', message: `${scale}${skipped}.` }
}