History panel + undo in the editor (#49) (#63)
Build image / build-and-push (push) Successful in 9s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #63.
This commit is contained in:
2026-07-21 05:23:25 +00:00
committed by steve
parent 3ec77a1099
commit 2a8fe24766
9 changed files with 725 additions and 41 deletions
+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>
)
}
+146
View File
@@ -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 (
<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 && sets.length === 0 && (
<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>
{/* 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 && (
<p className="text-xs text-red-700 dark:text-red-400">
{errorMessage(history.error, "Couldn't load older entries.")}
</p>
)}
</>
)}
{/* 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 = (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()
}
+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>
)
}
+10
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 }),
@@ -94,5 +103,6 @@ export const useEditorStore = create<EditorState>((set) => ({
armedKind: null,
liveObject: null,
livePlanting: null,
railTab: null,
}),
}))