History panel and undo in the editor (#49)
Build image / build-and-push (push) Successful in 9s
Gadfly review (reusable) / review (pull_request) Successful in 7m38s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m38s

#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:
2026-07-21 01:13:19 -04:00
co-authored by Claude Opus 4.8
parent 3ec77a1099
commit 4b18ac3b46
9 changed files with 682 additions and 30 deletions
+2 -1
View File
@@ -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
+88
View File
@@ -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>
)
}
+131
View File
@@ -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()
}
+51
View File
@@ -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>
)
}
+9
View File
@@ -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 }),
+103
View File
@@ -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)
})
})
+218
View File
@@ -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}.` }
}
+5 -1
View File
@@ -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({
+75 -28
View File
@@ -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 && (