From 2a8fe247662769898ab3243265897e1abd57e66f Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Tue, 21 Jul 2026 05:23:25 +0000 Subject: [PATCH] History panel + undo in the editor (#49) (#63) Co-authored-by: Steve Dudenhoeffer --- DESIGN.md | 3 +- web/src/editor/EditorRail.tsx | 88 +++++++++++ web/src/editor/HistoryPanel.tsx | 146 +++++++++++++++++++ web/src/editor/UndoButton.tsx | 51 +++++++ web/src/editor/store.ts | 10 ++ web/src/lib/history.test.ts | 115 +++++++++++++++ web/src/lib/history.ts | 226 +++++++++++++++++++++++++++++ web/src/lib/objects.ts | 6 +- web/src/pages/GardenEditorPage.tsx | 121 ++++++++++----- 9 files changed, 725 insertions(+), 41 deletions(-) create mode 100644 web/src/editor/EditorRail.tsx create mode 100644 web/src/editor/HistoryPanel.tsx create mode 100644 web/src/editor/UndoButton.tsx create mode 100644 web/src/lib/history.test.ts create mode 100644 web/src/lib/history.ts diff --git a/DESIGN.md b/DESIGN.md index cfd16aa..70508b5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/web/src/editor/EditorRail.tsx b/web/src/editor/EditorRail.tsx new file mode 100644 index 0000000..2aa76b4 --- /dev/null +++ b/web/src/editor/EditorRail.tsx @@ -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 ( +
+
+ {tabs.map((tab) => ( + + ))} + +
+
{active.render()}
+
+ ) +} diff --git a/web/src/editor/HistoryPanel.tsx b/web/src/editor/HistoryPanel.tsx new file mode 100644 index 0000000..5762f31 --- /dev/null +++ b/web/src/editor/HistoryPanel.tsx @@ -0,0 +1,146 @@ +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 ( +
+

History

+ + {history.isPending &&

Loading…

} + {history.isError && sets.length === 0 && ( + {errorMessage(history.error, 'Could not load history.')} + )} + + {history.isSuccess && sets.length === 0 && ( +

+ Nothing yet. Every change you make here shows up in this list, and can be undone from it. +

+ )} + +
    + {sets.map((cs) => ( + + ))} +
+ + {history.hasNextPage && ( + <> + + {/* isError stays true for a failed page even though earlier pages + loaded, so say so rather than leaving a button that did nothing. */} + {history.isError && sets.length > 0 && ( +

+ {errorMessage(history.error, "Couldn't load older entries.")} +

+ )} + + )} + + {/* 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. */} +

+ Deleting a whole garden isn't covered here and can't be undone. +

+
+ ) +} + +function HistoryEntry({ + changeSet, + canEdit, + undo, +}: { + changeSet: ChangeSet + canEdit: boolean + undo: ReturnType +}) { + const counts = describeCounts(changeSet) + const reverted = changeSet.revertedById != null + const isRevert = changeSet.revertsId != null + + return ( +
  • +
    +
    +

    {changeSet.summary}

    +

    + {changeSet.source === 'agent' && ( + + agent + + )} + {isRevert && undo} + {changeSet.actorName} + · + + {counts && ( + <> + · + {counts} + + )} +

    + {reverted &&

    Undone

    } +
    + + {canEdit && totalChanges(changeSet) > 0 && ( + + )} +
    +
  • + ) +} + +/** 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 = (Date.now() - then) / 1000 + // Clock skew between the server and this browser can put a just-written entry + // slightly in the future; that's "just now", not a negative duration. + if (seconds < 60) return 'just now' + // Floor throughout: 18 hours ago is "18h ago", not "yesterday". Rounding up + // into the next unit reads as a bigger gap than actually elapsed. + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days === 1) return 'yesterday' + if (days < 30) return `${days}d ago` + return new Date(then).toLocaleDateString() +} diff --git a/web/src/editor/UndoButton.tsx b/web/src/editor/UndoButton.tsx new file mode 100644 index 0000000..f97ad66 --- /dev/null +++ b/web/src/editor/UndoButton.tsx @@ -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 + className?: string + label?: string +}) { + const outcome = undo.outcomeFor(changeSet.id) + return ( +
    + + {outcome && outcome.tone !== 'pending' && } +
    + ) +} + +const TONE_CLASS: Record, 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 ( +

    + {outcome.message} +

    + ) +} diff --git a/web/src/editor/store.ts b/web/src/editor/store.ts index dfe194c..d479bcd 100644 --- a/web/src/editor/store.ts +++ b/web/src/editor/store.ts @@ -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((set) => ({ focusedObjectId: null, setFocusedObject: (id) => set({ focusedObjectId: id }), + railTab: null, + setRailTab: (tab) => set({ railTab: tab }), + armedPlant: null, setArmedPlant: (p) => set({ armedPlant: p }), @@ -94,5 +103,6 @@ export const useEditorStore = create((set) => ({ armedKind: null, liveObject: null, livePlanting: null, + railTab: null, }), })) diff --git a/web/src/lib/history.test.ts b/web/src/lib/history.test.ts new file mode 100644 index 0000000..e098759 --- /dev/null +++ b/web/src/lib/history.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { describeConflict, describeCounts, describeUndo, totalChanges, type ChangeSet } from './history' + +function changeSet(over: Partial = {}): 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, counts: [{ entityType: 'object', op: 'update', n: 3 }] }), + conflicts: [], + }) + expect(out).toEqual({ tone: 'ok', message: 'Undone.' }) + }) + + // 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 reports work that didn't happen. + it("doesn't claim to have undone a no-op", () => { + const out = describeUndo(target, { changeSet: null, conflicts: [] }) + expect(out.tone).toBe('ok') + expect(out.message).toBe('Nothing left to undo — this was already reversed.') + }) + + // 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) + }) +}) diff --git a/web/src/lib/history.ts b/web/src/lib/history.ts new file mode 100644 index 0000000..f3e1a19 --- /dev/null +++ b/web/src/lib/history.ts @@ -0,0 +1,226 @@ +// 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 + +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 + +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 + +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 + +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 + +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 => + 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 = { + garden: ['garden setting', 'garden settings'], + object: ['bed', 'beds'], + planting: ['planting', 'plantings'], +} + +const OP_VERBS: Record = { + 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>({}) + + 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, + // 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], + } +} + +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('; ') + const applied = result.changeSet ? totalChanges(result.changeSet) : 0 + if (result.conflicts.length === 0) { + // 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 (applied === 0) { + return { tone: 'error', message: `Nothing was undone — ${skipped}.` } + } + const total = totalChanges(target) + return { tone: 'partial', message: `${applied} of ${total} changes undone — ${skipped}.` } +} diff --git a/web/src/lib/objects.ts b/web/src/lib/objects.ts index 3077f0a..322414c 100644 --- a/web/src/lib/objects.ts +++ b/web/src/lib/objects.ts @@ -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({ diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index 2a979d4..92541a2 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -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,11 +40,10 @@ 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) - const setLiveObject = useEditorStore((s) => s.setLiveObject) - const setLivePlanting = useEditorStore((s) => s.setLivePlanting) const updatePlanting = useUpdatePlanting(gid) const updateObject = useUpdateObject(gid) @@ -79,14 +80,9 @@ export function GardenEditorPage() { // gardens (and on leaving). `focus` is intentionally read once here, not a dep, // so URL syncs below don't retrigger a full reset. useEffect(() => { - const clear = () => { - select(null) - selectPlanting(null) - setArmedKind(null) - setArmedPlant(null) - setLiveObject(null) - setLivePlanting(null) - } + // resetTransient is the single list of ephemeral editor state, so new state + // (the rail tab did exactly this) can't be forgotten here. + const clear = () => useEditorStore.getState().resetTransient() clear() setFocusedObject(focus ?? null) return () => { @@ -110,6 +106,17 @@ 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. Keyed off the + // selected ids rather than a "something is selected" boolean, so selecting a + // DIFFERENT object while History is open still brings the inspector forward. + // 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. + useEffect(() => { + if (selectedId != null || selectedPlantingId != null) setRailTab('inspector') + else if (useEditorStore.getState().railTab === 'inspector') setRailTab(null) + }, [selectedId, selectedPlantingId, setRailTab]) + const exitFocus = () => { setFocusedObject(null) setArmedPlant(null) @@ -270,6 +277,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 ? ( + { + setFocusedObject(selectedObject.id) + select(null) + }} + /> + ) : selectedPlop ? ( + setPicker('change')} + onClose={() => selectPlanting(null)} + /> + ) : ( +

    Select a bed or a planting to edit it.

    + ), + }, + { + id: 'history', + label: 'History', + render: () => , + }, + ] + return (
    @@ -285,6 +334,13 @@ export function GardenEditorPage() {

    👁 View only

    )} {focusedObjectId == null && canEdit && } +
    @@ -335,34 +391,21 @@ export function GardenEditorPage() { )}
    - {(selectedObject || selectedPlop) && ( -
    - {selectedObject && ( - { - setFocusedObject(selectedObject.id) - select(null) - }} - /> - )} - {selectedPlop && ( - setPicker('change')} - onClose={() => selectPlanting(null)} - /> - )} -
    + {railTab && ( + { + // Only the inspector is *about* the selection, so only closing it + // deselects; dismissing History leaves the canvas as you had it. + if (railTab === 'inspector') { + select(null) + selectPlanting(null) + } + setRailTab(null) + }} + /> )} {picker && (