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