Plants mode: recent-planted quick strip + clump/rows fill (#100)
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 9m44s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m44s

Two things Steve asked for, both in the #99 Plants mode:

- A "Recent" strip of the plants most recently planted IN THIS GARDEN
  (recentlyPlantedIds — derived from actual plantings, newest first, unique;
  NOT the manual localStorage tray), as tap-to-arm chips. So re-planting
  "more of the same" is one tap, no picker. Hidden until something's planted.
- A clump/rows fill control (FillControl), shown once a plant is armed:
  pick a layout, "Fill bed", and it runs POST /objects/:id/fill region=all
  with the chosen layout — the #77 grid/clump fill the UI could NOT reach
  before (it was agent/REST only). Defaults to rows (a real planting).
  useFillObject mirrors useClearObject: one request, invalidate /full.

Both live in the shared PlantPlacementTools, so desktop's focus toolbar and
the mobile Plants strip get them identically. Fill is capped/validated
server-side (#95) and covered spots are skipped, so re-filling is safe.

Verified live at 390px: the Recent strip shows this garden's tomato/lettuce/
garlic; arming a chip reveals Clump|Rows + Fill bed; fill fires cleanly.
tsc + vitest (95, incl. recentlyPlantedIds) + build green. DESIGN updated.

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-22 01:49:24 -04:00
co-authored by Claude Opus 4.8
parent 90af03d597
commit 97c8a36bac
6 changed files with 215 additions and 3 deletions
+70 -1
View File
@@ -12,6 +12,7 @@ import { PlopInspector } from '@/editor/PlopInspector'
import { PlantPicker } from '@/editor/PlantPicker'
import { Palette } from '@/editor/Palette'
import { SeedTray } from '@/editor/SeedTray'
import { RecentPlants } from '@/editor/RecentPlants'
import { ClearBedModal } from '@/editor/ClearBedModal'
import { EditorHint } from '@/editor/EditorHint'
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
@@ -24,13 +25,15 @@ import { useMe } from '@/lib/auth'
import {
toEditorObject,
useEnsurePlantInFull,
useFillObject,
useGardenFull,
useGardenSeason,
useGardenYears,
useUpdateObject,
useUpdatePlanting,
type FillLayout,
} from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings'
import { recentlyPlantedIds, toEditorPlanting } from '@/lib/plantings'
import type { Plant } from '@/lib/plants'
import { useCapabilities } from '@/lib/agent'
import { useJournalCounts } from '@/lib/journal'
@@ -88,6 +91,7 @@ export function GardenEditorPage() {
const setMode = useEditorStore((s) => s.setMode)
const updatePlanting = useUpdatePlanting(gid)
const fillObject = useFillObject(gid)
const updateObject = useUpdateObject(gid)
const ensurePlant = useEnsurePlantInFull(gid)
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
@@ -109,6 +113,17 @@ export function GardenEditorPage() {
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
// Plants recently placed in THIS garden, newest first (#100) — the quick strip
// in Plants mode, so re-planting "more of the same" doesn't need the picker.
const recentPlants = useMemo(
() =>
recentlyPlantedIds(plantings)
.slice(0, 8)
.map((id) => plantsById.get(id))
.filter((p): p is Plant => !!p),
[plantings, plantsById],
)
// Role gating, computed before the effects/returns so the nudge handler can use
// it. Ownership is the authoritative ownerId==me check.
const gd = full.data?.garden
@@ -393,6 +408,14 @@ export function GardenEditorPage() {
if (armedPlant?.id === id) setArmedPlant(null)
}
// Fill the whole focused bed with the armed plant at the chosen layout (#100 /
// #77) — the UI's way to run the region fill that was agent-only before. Guards
// are belt-and-braces: the control only shows with a plant armed in a bed.
function fillBed(layout: FillLayout) {
if (!canEdit || focusedObject == null || armedPlant == null) return
fillObject.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, layout })
}
// The picker hands back the full Plant (from the whole catalog), so use it
// directly rather than re-resolving against the garden's referenced-plant map —
// a not-yet-placed plant isn't in that map, which used to silently abort the
@@ -572,6 +595,7 @@ export function GardenEditorPage() {
{canEdit &&
(focusedObject.plantable ? (
<PlantPlacementTools
recentPlants={recentPlants}
trayPlants={trayPlants}
armedPlant={armedPlant}
onArm={armPlant}
@@ -580,6 +604,8 @@ export function GardenEditorPage() {
onDisarm={() => setArmedPlant(null)}
focusedPlopCount={focusedPlops.length}
onClear={() => setClearing(true)}
onFill={fillBed}
filling={fillObject.isPending}
/>
) : (
<span className="text-xs text-muted">Not plantable</span>
@@ -611,6 +637,7 @@ export function GardenEditorPage() {
focusedObject.plantable ? (
<div className="flex flex-wrap items-center gap-2">
<PlantPlacementTools
recentPlants={recentPlants}
trayPlants={trayPlants}
armedPlant={armedPlant}
onArm={armPlant}
@@ -619,6 +646,8 @@ export function GardenEditorPage() {
onDisarm={() => setArmedPlant(null)}
focusedPlopCount={focusedPlops.length}
onClear={() => setClearing(true)}
onFill={fillBed}
filling={fillObject.isPending}
/>
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
Done planting
@@ -694,6 +723,7 @@ export function GardenEditorPage() {
// The plant-placement cluster (seed tray + Done + Clear), shared by the desktop
// focus toolbar and the mobile Plants strip so the two can't drift apart.
function PlantPlacementTools({
recentPlants,
trayPlants,
armedPlant,
onArm,
@@ -702,7 +732,10 @@ function PlantPlacementTools({
onDisarm,
focusedPlopCount,
onClear,
onFill,
filling,
}: {
recentPlants: Plant[]
trayPlants: Plant[]
armedPlant: Plant | null
onArm: (p: Plant, lot?: SeedLot) => void
@@ -711,9 +744,12 @@ function PlantPlacementTools({
onDisarm: () => void
focusedPlopCount: number
onClear: () => void
onFill: (layout: FillLayout) => void
filling: boolean
}) {
return (
<>
<RecentPlants plants={recentPlants} armedPlantId={armedPlant?.id ?? null} onArm={onArm} />
<SeedTray
trayPlants={trayPlants}
armedPlantId={armedPlant?.id ?? null}
@@ -721,6 +757,7 @@ function PlantPlacementTools({
onRemove={onRemove}
onOpenPicker={onOpenPicker}
/>
{armedPlant && <FillControl onFill={onFill} busy={filling} />}
{armedPlant && (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onDisarm}>
Done
@@ -739,6 +776,38 @@ function PlantPlacementTools({
)
}
// The fill control (#100 / #77): choose a layout, then fill the whole bed with
// the armed plant. Clump packs fat blobs for a quick sketch; grid lays out
// individual plants in rows you could actually plant from. Defaults to grid,
// since "fill this bed" usually means a real planting.
function FillControl({ onFill, busy }: { onFill: (layout: FillLayout) => void; busy: boolean }) {
const [layout, setLayout] = useState<FillLayout>('grid')
return (
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface px-1 py-0.5 text-xs">
<span className="inline-flex overflow-hidden rounded-full">
{(['clump', 'grid'] as const).map((l) => (
<button
key={l}
type="button"
aria-pressed={layout === l}
onClick={() => setLayout(l)}
title={l === 'clump' ? 'Fat clumps — a quick sketch' : 'Rows of plants you could plant from'}
className={cn(
'px-2 py-0.5 font-medium capitalize transition-colors',
layout === l ? 'bg-border/70 text-accent-strong' : 'text-muted hover:text-fg',
)}
>
{l === 'grid' ? 'rows' : l}
</button>
))}
</span>
<Button variant="ghost" className="px-2 py-0.5 text-xs" disabled={busy} onClick={() => onFill(layout)}>
{busy ? 'Filling…' : 'Fill bed'}
</Button>
</span>
)
}
// The mobile primary mode switch (#99): one always-there tab bar so "placing
// beds", "planting", "journaling" and "assistant" stop competing for the same
// strip. Assistant is dropped when the instance has no model configured.