History panel and undo in the editor (#49)
#48 made every change revertible; this makes that reachable. The bar was that undoing what the agent just did should take one obvious click, not a hunt. Settles the rail-layout question, which is the part #53 and #57 depend on. Four things wanted one strip of screen — inspector, history, journal, chat — so they are tabs in one EditorRail rather than each bolting on its own chrome. That keeps the canvas at one width instead of a different width per panel, and adding the journal or chat later is adding a tab. Two constraints held. Selecting an object still lands you in the inspector with no extra click: the page watches the selection and switches tabs itself, so the rail never becomes something you operate before you can edit. And the canvas stays worth watching while the agent edits it — the rail is a fixed 20rem column that closes completely when nothing needs it. On a phone the same tabs render in the bottom sheet the inspector already lived in. A reverted entry stays in the list, struck through and marked, and the revert appears as its own entry — because that is what it is. Making the original disappear would be rewriting history rather than appending to it, and would leave no way to undo the undo. Conflicts are reported as what actually happened. A 409 from a revert is not a plain failure: it carries the change set that DID apply alongside the entities deliberately left alone, so the message is "2 of 3 changes undone — “North Bed” was edited since, so it was left alone" rather than a generic toast. A bare failure would be a lie about the two that applied; a bare success would hide the one that didn't. Undo is one implementation, not two: useUndo owns the mutation, the per-change- set outcome and the phrasing, and UndoButton renders it. #57's inline undo on an agent turn uses the same hook, so undo behaves identically wherever it appears. The panel says plainly that deleting a whole garden isn't covered and can't be undone, rather than leaving that to be discovered. Closes #49 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -105,7 +105,8 @@ 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), `Inspector` (side panel; bottom sheet on mobile), `PlantPicker`. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
|
||||
- **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, and later the journal and 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.
|
||||
|
||||
## Roadmap
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/**
|
||||
* The editor's side rail, and the answer to "four things want one rail".
|
||||
*
|
||||
* The inspector, history, and (later) the journal and chat panel all want the
|
||||
* same strip of screen. Rather than each bolting on its own chrome, they are
|
||||
* tabs in one rail — which keeps the canvas one width instead of a different
|
||||
* width per panel, and means adding the journal or chat is adding a tab.
|
||||
*
|
||||
* Two constraints shaped it:
|
||||
*
|
||||
* - Selecting an object must land you in the inspector with no extra click.
|
||||
* The editor watches the selection and switches to that tab itself, so the
|
||||
* rail never becomes a thing you have to operate before you can edit.
|
||||
* - The canvas has to stay worth watching while the agent edits it, so the
|
||||
* rail is a fixed 20rem column and closes completely when nothing needs it.
|
||||
*
|
||||
* On a phone the same tabs render in a bottom sheet, which is where the
|
||||
* inspector already lived.
|
||||
*/
|
||||
|
||||
export interface RailTab {
|
||||
id: string
|
||||
label: string
|
||||
/** Rendered lazily: only the active tab's content is built. */
|
||||
render: () => ReactNode
|
||||
/** Shown as a dot on the tab — a count, or true for a plain marker. */
|
||||
badge?: number | boolean
|
||||
}
|
||||
|
||||
export function EditorRail({
|
||||
tabs,
|
||||
activeId,
|
||||
onActivate,
|
||||
onClose,
|
||||
}: {
|
||||
tabs: RailTab[]
|
||||
activeId: string
|
||||
onActivate: (id: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const active = tabs.find((t) => t.id === activeId) ?? tabs[0]
|
||||
if (!active) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Phone: a bottom sheet over the canvas. Desktop: a column beside it.
|
||||
'fixed inset-x-0 bottom-0 z-30 flex max-h-[70vh] flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
|
||||
'md:static md:max-h-none md:w-80 md:shrink-0 md:rounded-xl md:border md:shadow-sm',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onActivate(tab.id)}
|
||||
aria-current={tab.id === active.id ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium outline-none transition-colors',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
tab.id === active.id ? 'bg-border/60 text-fg' : 'text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge != null && tab.badge !== false && tab.badge !== 0 && (
|
||||
<span className="rounded-full bg-accent/20 px-1.5 text-[10px] font-semibold text-accent-strong">
|
||||
{tab.badge === true ? '•' : tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
className="ml-auto rounded px-1.5 text-sm text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">{active.render()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { describeCounts, totalChanges, useGardenHistory, useUndo, type ChangeSet } from '@/lib/history'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { UndoButton } from './UndoButton'
|
||||
|
||||
/**
|
||||
* The garden's change history, newest first, with an undo on each entry.
|
||||
*
|
||||
* A reverted entry stays in the list, marked — and the revert appears as its own
|
||||
* entry, because that is what it is. Making the original disappear would be
|
||||
* rewriting history rather than adding to it, and would leave no way to undo the
|
||||
* undo.
|
||||
*/
|
||||
export function HistoryPanel({ gardenId, canEdit }: { gardenId: number; canEdit: boolean }) {
|
||||
const history = useGardenHistory(gardenId)
|
||||
const undo = useUndo(gardenId)
|
||||
const sets = history.data?.pages.flatMap((p) => p.changeSets) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold text-fg">History</h2>
|
||||
|
||||
{history.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||
{history.isError && <Alert>{errorMessage(history.error, 'Could not load history.')}</Alert>}
|
||||
|
||||
{history.isSuccess && sets.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
Nothing yet. Every change you make here shows up in this list, and can be undone from it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ol className="flex flex-col gap-2">
|
||||
{sets.map((cs) => (
|
||||
<HistoryEntry key={cs.id} changeSet={cs} canEdit={canEdit} undo={undo} />
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{history.hasNextPage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-sm"
|
||||
disabled={history.isFetchingNextPage}
|
||||
onClick={() => void history.fetchNextPage()}
|
||||
>
|
||||
{history.isFetchingNextPage ? 'Loading…' : 'Load older'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Said plainly rather than discovered: deleting a garden bypasses this
|
||||
list entirely, because the delete cascades below the layer that records
|
||||
changes. Better to state the gap than to imply cover we don't have. */}
|
||||
<p className="border-t border-border pt-2 text-xs text-muted">
|
||||
Deleting a whole garden isn't covered here and can't be undone.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryEntry({
|
||||
changeSet,
|
||||
canEdit,
|
||||
undo,
|
||||
}: {
|
||||
changeSet: ChangeSet
|
||||
canEdit: boolean
|
||||
undo: ReturnType<typeof useUndo>
|
||||
}) {
|
||||
const counts = describeCounts(changeSet)
|
||||
const reverted = changeSet.revertedById != null
|
||||
const isRevert = changeSet.revertsId != null
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'rounded-lg border border-border px-2.5 py-2 text-sm',
|
||||
reverted && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('text-fg', reverted && 'line-through decoration-muted')}>{changeSet.summary}</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
|
||||
{changeSet.source === 'agent' && (
|
||||
<span className="rounded bg-accent/20 px-1 py-px font-medium uppercase tracking-wide text-accent-strong">
|
||||
agent
|
||||
</span>
|
||||
)}
|
||||
{isRevert && <span className="rounded bg-border/60 px-1 py-px font-medium">undo</span>}
|
||||
<span>{changeSet.actorName}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<time dateTime={changeSet.createdAt}>{relativeTime(changeSet.createdAt)}</time>
|
||||
{counts && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{counts}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{reverted && <p className="mt-1 text-xs text-muted">Undone</p>}
|
||||
</div>
|
||||
|
||||
{canEdit && totalChanges(changeSet) > 0 && (
|
||||
<UndoButton
|
||||
changeSet={changeSet}
|
||||
undo={undo}
|
||||
className="w-32 shrink-0"
|
||||
label={reverted ? 'Undo again' : 'Undo'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** A compact "3m ago" / "yesterday" for a UTC timestamp. */
|
||||
export function relativeTime(iso: string): string {
|
||||
const then = Date.parse(iso)
|
||||
if (Number.isNaN(then)) return iso
|
||||
const seconds = Math.round((Date.now() - then) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.round(hours / 24)
|
||||
if (days === 1) return 'yesterday'
|
||||
if (days < 30) return `${days}d ago`
|
||||
return new Date(then).toLocaleDateString()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { ChangeSet, UndoOutcome, useUndo } from '@/lib/history'
|
||||
|
||||
/**
|
||||
* Undo, plus what happened. Paired with useUndo so the history list and the
|
||||
* agent turn's inline undo (#57) behave identically — including the part that
|
||||
* actually matters, which is reporting a partial result honestly rather than as
|
||||
* a success or a failure.
|
||||
*/
|
||||
export function UndoButton({
|
||||
changeSet,
|
||||
undo,
|
||||
className,
|
||||
label = 'Undo',
|
||||
}: {
|
||||
changeSet: ChangeSet
|
||||
undo: ReturnType<typeof useUndo>
|
||||
className?: string
|
||||
label?: string
|
||||
}) {
|
||||
const outcome = undo.outcomeFor(changeSet.id)
|
||||
return (
|
||||
<div className={cn('flex flex-col items-end gap-1', className)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
disabled={outcome?.tone === 'pending'}
|
||||
onClick={() => undo.undo(changeSet)}
|
||||
>
|
||||
{outcome?.tone === 'pending' ? 'Undoing…' : label}
|
||||
</Button>
|
||||
{outcome && outcome.tone !== 'pending' && <OutcomeNote outcome={outcome} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TONE_CLASS: Record<Exclude<UndoOutcome['tone'], 'pending'>, string> = {
|
||||
ok: 'text-muted',
|
||||
partial: 'text-amber-700 dark:text-amber-400',
|
||||
error: 'text-red-700 dark:text-red-400',
|
||||
}
|
||||
|
||||
function OutcomeNote({ outcome }: { outcome: UndoOutcome }) {
|
||||
if (outcome.tone === 'pending') return null
|
||||
return (
|
||||
<p role="status" className={cn('text-right text-xs', TONE_CLASS[outcome.tone])}>
|
||||
{outcome.message}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,12 @@ interface EditorState {
|
||||
focusedObjectId: number | null
|
||||
setFocusedObject: (id: number | null) => void
|
||||
|
||||
// Which rail tab is showing, or null when the rail is closed. Selecting an
|
||||
// object switches this to 'inspector' (see GardenEditorPage), so editing never
|
||||
// starts with a click on the rail itself.
|
||||
railTab: string | null
|
||||
setRailTab: (tab: string | null) => void
|
||||
|
||||
// The plant armed for placing plops (set after the PlantPicker choice); stays
|
||||
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
||||
armedPlant: Plant | null
|
||||
@@ -70,6 +76,9 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
focusedObjectId: null,
|
||||
setFocusedObject: (id) => set({ focusedObjectId: id }),
|
||||
|
||||
railTab: null,
|
||||
setRailTab: (tab) => set({ railTab: tab }),
|
||||
|
||||
armedPlant: null,
|
||||
setArmedPlant: (p) => set({ armedPlant: p }),
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describeConflict, describeCounts, describeUndo, totalChanges, type ChangeSet } from './history'
|
||||
|
||||
function changeSet(over: Partial<ChangeSet> = {}): ChangeSet {
|
||||
return {
|
||||
id: 1,
|
||||
gardenId: 1,
|
||||
actorId: 1,
|
||||
actorName: 'Steve',
|
||||
source: 'ui',
|
||||
summary: 'Did a thing',
|
||||
counts: [],
|
||||
createdAt: '2026-07-21T00:00:00Z',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('describeCounts', () => {
|
||||
it('pluralizes per entity and names the operation', () => {
|
||||
const cs = changeSet({
|
||||
counts: [
|
||||
{ entityType: 'planting', op: 'create', n: 3 },
|
||||
{ entityType: 'object', op: 'update', n: 1 },
|
||||
],
|
||||
})
|
||||
expect(describeCounts(cs)).toBe('3 plantings added, 1 bed changed')
|
||||
})
|
||||
|
||||
it('says nothing rather than "0 changes"', () => {
|
||||
expect(describeCounts(changeSet())).toBe('')
|
||||
expect(describeCounts(changeSet({ counts: [{ entityType: 'object', op: 'create', n: 0 }] }))).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeConflict', () => {
|
||||
it('names the entity when it has a name', () => {
|
||||
expect(describeConflict({ entityType: 'object', entityId: 4, reason: 'changed', name: 'North Bed' })).toBe(
|
||||
'“North Bed” was edited since, so it was left alone',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the entity kind when it has no name', () => {
|
||||
expect(describeConflict({ entityType: 'planting', entityId: 9, reason: 'missing' })).toBe(
|
||||
'that planting no longer exists',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeUndo', () => {
|
||||
const target = changeSet({ counts: [{ entityType: 'object', op: 'update', n: 3 }] })
|
||||
|
||||
it('is a plain success when nothing conflicted', () => {
|
||||
const out = describeUndo(target, { changeSet: changeSet({ id: 2 }), conflicts: [] })
|
||||
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
|
||||
})
|
||||
|
||||
// The case the issue was specific about: a partial revert really did change
|
||||
// things, so reporting a bare failure would be a lie about what applied.
|
||||
it('says how much applied and what was skipped', () => {
|
||||
const out = describeUndo(target, {
|
||||
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 2 }] }),
|
||||
conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }],
|
||||
})
|
||||
expect(out.tone).toBe('partial')
|
||||
expect(out.message).toBe('2 of 3 changes undone — “North Bed” was edited since, so it was left alone.')
|
||||
})
|
||||
|
||||
it('is an error when nothing applied at all', () => {
|
||||
const out = describeUndo(target, {
|
||||
changeSet: null,
|
||||
conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }],
|
||||
})
|
||||
expect(out.tone).toBe('error')
|
||||
expect(out.message).toBe('Nothing was undone — “North Bed” was edited since, so it was left alone.')
|
||||
})
|
||||
|
||||
it('lists every conflict, not just the first', () => {
|
||||
const out = describeUndo(target, {
|
||||
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 1 }] }),
|
||||
conflicts: [
|
||||
{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' },
|
||||
{ entityType: 'planting', entityId: 8, reason: 'missing' },
|
||||
],
|
||||
})
|
||||
expect(out.message).toContain('“North Bed”')
|
||||
expect(out.message).toContain('that planting no longer exists')
|
||||
})
|
||||
})
|
||||
|
||||
describe('totalChanges', () => {
|
||||
it('sums every tally', () => {
|
||||
expect(
|
||||
totalChanges(
|
||||
changeSet({
|
||||
counts: [
|
||||
{ entityType: 'planting', op: 'create', n: 12 },
|
||||
{ entityType: 'object', op: 'update', n: 1 },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(13)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
// 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']),
|
||||
n: z.number(),
|
||||
})
|
||||
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. */
|
||||
export function totalChanges(cs: ChangeSet): 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: ChangeSet) => {
|
||||
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,
|
||||
outcomeFor: (id: number): UndoOutcome | undefined => outcomes[id],
|
||||
isPending: revert.isPending,
|
||||
}
|
||||
}
|
||||
|
||||
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: ChangeSet, result: RevertResult): UndoOutcome {
|
||||
const skipped = result.conflicts.map(describeConflict).join('; ')
|
||||
if (result.conflicts.length === 0) {
|
||||
return { tone: 'ok', message: 'Undone.' }
|
||||
}
|
||||
const applied = result.changeSet ? totalChanges(result.changeSet) : 0
|
||||
if (applied === 0) {
|
||||
return { tone: 'error', message: `Nothing was undone — ${skipped}.` }
|
||||
}
|
||||
const total = totalChanges(target)
|
||||
return { tone: 'partial', message: `${applied} of ${total} changes undone — ${skipped}.` }
|
||||
}
|
||||
@@ -69,7 +69,11 @@ export function toEditorObject(o: ServerObject): EditorObject {
|
||||
}
|
||||
}
|
||||
|
||||
const fullKey = (gardenId: number) => ['garden-full', gardenId] as const
|
||||
// Exported so anything that changes a garden's contents from outside this file
|
||||
// — undo, in particular — can invalidate the editor payload and make the canvas
|
||||
// redraw.
|
||||
export const gardenFullKey = (gardenId: number) => ['garden-full', gardenId] as const
|
||||
const fullKey = gardenFullKey
|
||||
|
||||
export function gardenFullQueryOptions(gardenId: number) {
|
||||
return queryOptions({
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getRouteApi } from '@tanstack/react-router'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { GardenCanvas } from '@/editor/GardenCanvas'
|
||||
import { EditorRail, type RailTab } from '@/editor/EditorRail'
|
||||
import { HistoryPanel } from '@/editor/HistoryPanel'
|
||||
import { Inspector } from '@/editor/Inspector'
|
||||
import { PlopInspector } from '@/editor/PlopInspector'
|
||||
import { PlantPicker } from '@/editor/PlantPicker'
|
||||
@@ -38,6 +40,8 @@ export function GardenEditorPage() {
|
||||
const selectPlanting = useEditorStore((s) => s.selectPlanting)
|
||||
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
|
||||
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
||||
const railTab = useEditorStore((s) => s.railTab)
|
||||
const setRailTab = useEditorStore((s) => s.setRailTab)
|
||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
|
||||
const setArmedKind = useEditorStore((s) => s.setArmedKind)
|
||||
@@ -86,6 +90,7 @@ export function GardenEditorPage() {
|
||||
setArmedPlant(null)
|
||||
setLiveObject(null)
|
||||
setLivePlanting(null)
|
||||
setRailTab(null)
|
||||
}
|
||||
clear()
|
||||
setFocusedObject(focus ?? null)
|
||||
@@ -110,6 +115,16 @@ export function GardenEditorPage() {
|
||||
}
|
||||
}, [focusedObjectId, objects, full.data, setFocusedObject])
|
||||
|
||||
// Selecting anything lands you in the inspector without a click — the rail
|
||||
// must never be something you operate before you can edit. Deselecting drops
|
||||
// back out of the inspector, but leaves History/Chat open if that's where you
|
||||
// were, since those aren't about the selection.
|
||||
const hasSelection = selectedId != null || selectedPlantingId != null
|
||||
useEffect(() => {
|
||||
if (hasSelection) setRailTab('inspector')
|
||||
else if (useEditorStore.getState().railTab === 'inspector') setRailTab(null)
|
||||
}, [hasSelection, setRailTab])
|
||||
|
||||
const exitFocus = () => {
|
||||
setFocusedObject(null)
|
||||
setArmedPlant(null)
|
||||
@@ -270,6 +285,48 @@ export function GardenEditorPage() {
|
||||
setPicker(null)
|
||||
}
|
||||
|
||||
// One rail, tabs inside it — the layout decision #49 settles for the journal
|
||||
// (#53) and chat (#57) panels too: they add a tab here rather than each
|
||||
// claiming their own strip of screen.
|
||||
const railTabs: RailTab[] = [
|
||||
{
|
||||
id: 'inspector',
|
||||
label: 'Inspector',
|
||||
render: () =>
|
||||
selectedObject ? (
|
||||
<Inspector
|
||||
key={`obj-${selectedObject.id}`}
|
||||
object={selectedObject}
|
||||
gardenId={gid}
|
||||
unit={garden.unitPref}
|
||||
readOnly={!canEdit}
|
||||
onFocus={() => {
|
||||
setFocusedObject(selectedObject.id)
|
||||
select(null)
|
||||
}}
|
||||
/>
|
||||
) : selectedPlop ? (
|
||||
<PlopInspector
|
||||
key={`plop-${selectedPlop.id}`}
|
||||
plop={selectedPlop}
|
||||
plant={plantsById.get(selectedPlop.plantId)}
|
||||
gardenId={gid}
|
||||
unit={garden.unitPref}
|
||||
readOnly={!canEdit}
|
||||
onChangePlant={() => setPicker('change')}
|
||||
onClose={() => selectPlanting(null)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted">Select a bed or a planting to edit it.</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'history',
|
||||
label: 'History',
|
||||
render: () => <HistoryPanel 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">
|
||||
@@ -285,6 +342,13 @@ export function GardenEditorPage() {
|
||||
<p className="mb-2 rounded-md bg-border/40 px-2 py-1 text-xs text-muted">👁 View only</p>
|
||||
)}
|
||||
{focusedObjectId == null && canEdit && <Palette />}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mt-2 w-full text-sm"
|
||||
onClick={() => setRailTab(railTab === 'history' ? null : 'history')}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
@@ -335,34 +399,17 @@ export function GardenEditorPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(selectedObject || selectedPlop) && (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 max-h-[65vh] overflow-y-auto rounded-t-xl border-t border-border bg-surface p-4 shadow-lg md:static md:max-h-none md:w-72 md:shrink-0 md:rounded-xl md:border md:p-4 md:shadow-sm">
|
||||
{selectedObject && (
|
||||
<Inspector
|
||||
key={`obj-${selectedObject.id}`}
|
||||
object={selectedObject}
|
||||
gardenId={gid}
|
||||
unit={garden.unitPref}
|
||||
readOnly={!canEdit}
|
||||
onFocus={() => {
|
||||
setFocusedObject(selectedObject.id)
|
||||
select(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectedPlop && (
|
||||
<PlopInspector
|
||||
key={`plop-${selectedPlop.id}`}
|
||||
plop={selectedPlop}
|
||||
plant={plantsById.get(selectedPlop.plantId)}
|
||||
gardenId={gid}
|
||||
unit={garden.unitPref}
|
||||
readOnly={!canEdit}
|
||||
onChangePlant={() => setPicker('change')}
|
||||
onClose={() => selectPlanting(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{railTab && (
|
||||
<EditorRail
|
||||
tabs={railTabs}
|
||||
activeId={railTab}
|
||||
onActivate={setRailTab}
|
||||
onClose={() => {
|
||||
setRailTab(null)
|
||||
select(null)
|
||||
selectPlanting(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{picker && (
|
||||
|
||||
Reference in New Issue
Block a user