diff --git a/DESIGN.md b/DESIGN.md
index aa669a1..0920f13 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -129,7 +129,7 @@ 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, and the mobile `mode`.
-- **Mobile-first editor: one primary mode (#99).** On a phone the canvas is the whole screen; a bottom mode bar switches which tools dock beneath it — **Fixtures** (the object palette), **Plants** (the seed tray, shown once a bed is focused; focusing a bed puts you in this mode), **Journal**, **Assistant** (the last two open the rail sheet; Assistant is hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. Desktop keeps its side-column layout (the mode bar is `md:hidden`) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary mode.
+- **Mobile-first editor: one primary mode (#99).** On a phone the canvas is the whole screen; a bottom mode bar switches which tools dock beneath it — **Fixtures** (the object palette), **Plants** (shown once a bed is focused; focusing a bed puts you in this mode — a "Recent" strip of what you've most recently planted *in this garden* (#100, derived from plantings, not the manual tray) for one-tap re-arming, the seed tray, the picker, and a clump/rows **fill** control that runs the region fill (#77) the UI couldn't reach before), **Journal**, **Assistant** (the last two open the rail sheet; Assistant is hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. Desktop keeps its side-column layout (the mode bar is `md:hidden`) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary mode.
- **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, journal and assistant 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.
diff --git a/web/src/components/plants/PlantChip.tsx b/web/src/components/plants/PlantChip.tsx
new file mode 100644
index 0000000..c7e46a4
--- /dev/null
+++ b/web/src/components/plants/PlantChip.tsx
@@ -0,0 +1,41 @@
+import { cn } from '@/lib/cn'
+import { PlantIcon } from '@/components/plants/PlantIcon'
+import type { Plant } from '@/lib/plants'
+
+/**
+ * A small tap-to-arm plant chip (icon + name), highlighted when it's the armed
+ * plant. Shared by the Seed Tray (which wraps it with a remove button) and the
+ * Recent-plants strip, so the two quick-pick surfaces stay visually identical.
+ * `rounded` is false when a caller (the tray) attaches a trailing control and
+ * needs a flat right edge.
+ */
+export function PlantChip({
+ plant,
+ active,
+ onArm,
+ rounded = true,
+}: {
+ plant: Plant
+ active: boolean
+ onArm: (plant: Plant) => void
+ rounded?: boolean
+}) {
+ return (
+
+ )
+}
diff --git a/web/src/editor/RecentPlants.tsx b/web/src/editor/RecentPlants.tsx
new file mode 100644
index 0000000..1f86fd2
--- /dev/null
+++ b/web/src/editor/RecentPlants.tsx
@@ -0,0 +1,29 @@
+import { PlantChip } from '@/components/plants/PlantChip'
+import type { Plant } from '@/lib/plants'
+
+/**
+ * A quick strip of the plants you've most recently planted IN THIS GARDEN (#100),
+ * so re-placing "more of the same" is one tap instead of a trip through the
+ * catalog or the manual tray. Derived from actual plantings (see
+ * recentlyPlantedIds), newest first; renders nothing until something's planted.
+ * Tap a chip to arm it for placement (the armed one is highlighted).
+ */
+export function RecentPlants({
+ plants,
+ armedPlantId,
+ onArm,
+}: {
+ plants: Plant[]
+ armedPlantId: number | null
+ onArm: (plant: Plant) => void
+}) {
+ if (plants.length === 0) return null
+ return (
+
+ Recent
+ {plants.map((p) => (
+
+ ))}
+
+ )
+}
diff --git a/web/src/editor/SeedTray.tsx b/web/src/editor/SeedTray.tsx
index cbfe6f1..06e6de6 100644
--- a/web/src/editor/SeedTray.tsx
+++ b/web/src/editor/SeedTray.tsx
@@ -1,5 +1,5 @@
import { cn } from '@/lib/cn'
-import { PlantIcon } from '@/components/plants/PlantIcon'
+import { PlantChip } from '@/components/plants/PlantChip'
import type { Plant } from '@/lib/plants'
/**
@@ -26,28 +26,19 @@ export function SeedTray({
{trayPlants.map((p) => {
const active = p.id === armedPlantId
return (
-
-
+
+ {/* Flat right edge so the remove button below seams into one pill. */}
+
diff --git a/web/src/lib/objects.ts b/web/src/lib/objects.ts
index 44dbf30..9c0a4e0 100644
--- a/web/src/lib/objects.ts
+++ b/web/src/lib/objects.ts
@@ -329,6 +329,38 @@ export function useUpdatePlanting(gardenId: number) {
}
const clearResultSchema = z.object({ cleared: z.number() })
+const fillResultSchema = z.object({ created: z.number() })
+
+/** Fill mode (#77/#100): the layout a region fill packs — fat clumps for quick
+ * coverage, or a grid of individual plants at true spacing you could plant from.
+ * Passed straight through to the server's `layout` field. */
+export type FillLayout = 'clump' | 'grid'
+
+/** Fill a whole plantable object with one plant at the chosen layout, via the
+ * same `POST /objects/:id/fill` the agent uses (region "all"). The response is
+ * just a count; invalidate rather than optimistically splice a hex lattice we'd
+ * have to recompute client-side. Returns how many plops it created. */
+export function useFillObject(gardenId: number) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: async ({
+ objectId,
+ plantId,
+ layout,
+ }: {
+ objectId: number
+ plantId: number
+ layout: FillLayout
+ }): Promise => {
+ const res = fillResultSchema.parse(
+ await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout }),
+ )
+ return res.created
+ },
+ onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
+ onError: (err) => toast.error(objectErrorMessage(err, 'Could not fill the bed.')),
+ })
+}
/** Clear a bed: soft-remove every active plop in an object (#82).
*
diff --git a/web/src/lib/plantings.test.ts b/web/src/lib/plantings.test.ts
index a8497d8..f86afca 100644
--- a/web/src/lib/plantings.test.ts
+++ b/web/src/lib/plantings.test.ts
@@ -1,5 +1,47 @@
import { describe, expect, it } from 'vitest'
-import { computeDerivedCount, effectiveCount } from './plantings'
+import { computeDerivedCount, effectiveCount, recentlyPlantedIds, type EditorPlanting } from './plantings'
+
+function plop(over: Partial): EditorPlanting {
+ return {
+ id: 0,
+ objectId: 1,
+ plantId: 1,
+ xCm: 0,
+ yCm: 0,
+ radiusCm: 5,
+ count: null,
+ derivedCount: 1,
+ label: null,
+ plantedAt: null,
+ version: 1,
+ ...over,
+ }
+}
+
+describe('recentlyPlantedIds', () => {
+ it('returns unique plant ids, newest planted first', () => {
+ const got = recentlyPlantedIds([
+ plop({ id: 1, plantId: 10, plantedAt: '2026-05-01' }),
+ plop({ id: 2, plantId: 20, plantedAt: '2026-07-01' }),
+ plop({ id: 3, plantId: 10, plantedAt: '2026-06-01' }), // dup plant, later
+ ])
+ // 20 (Jul) before 10 (its most recent plop is Jun); 10 appears once.
+ expect(got).toEqual([20, 10])
+ })
+
+ it('breaks a same-day tie by newer plop id, and sorts undated last', () => {
+ const got = recentlyPlantedIds([
+ plop({ id: 5, plantId: 30, plantedAt: null }),
+ plop({ id: 6, plantId: 40, plantedAt: '2026-07-01' }),
+ plop({ id: 7, plantId: 50, plantedAt: '2026-07-01' }),
+ ])
+ expect(got).toEqual([50, 40, 30]) // id 7 > 6 on the same day; undated 30 last
+ })
+
+ it('is empty for no plantings', () => {
+ expect(recentlyPlantedIds([])).toEqual([])
+ })
+})
describe('computeDerivedCount', () => {
it('mirrors the server formula max(1, round(π·r²/spacing²))', () => {
diff --git a/web/src/lib/plantings.ts b/web/src/lib/plantings.ts
index a1d0261..d237965 100644
--- a/web/src/lib/plantings.ts
+++ b/web/src/lib/plantings.ts
@@ -60,6 +60,27 @@ export function effectiveCount(p: { count: number | null; derivedCount: number }
return p.count ?? p.derivedCount
}
+/**
+ * Plant ids a garden has been planted with, most recent first and de-duplicated —
+ * the "what have I actually been planting here" quick list (#100). Ordered by
+ * plantedAt (a plop with no date sorts last), then by id so newer plops win a
+ * same-day tie. The caller resolves the ids to plants against the catalog.
+ */
+export function recentlyPlantedIds(plantings: EditorPlanting[]): number[] {
+ const sorted = [...plantings].sort(
+ (a, b) => (b.plantedAt ?? '').localeCompare(a.plantedAt ?? '') || b.id - a.id,
+ )
+ const seen = new Set()
+ const ids: number[] = []
+ for (const p of sorted) {
+ if (!seen.has(p.plantId)) {
+ seen.add(p.plantId)
+ ids.push(p.plantId)
+ }
+ }
+ return ids
+}
+
/** Client-side mirror of the server's derived-count formula, for live display
* while resizing a plop (before the PATCH round-trips). max(1, round(π·r² /
* spacing²)), capped like the server. */
diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx
index 5816421..dac74ee 100644
--- a/web/src/pages/GardenEditorPage.tsx
+++ b/web/src/pages/GardenEditorPage.tsx
@@ -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'
@@ -42,6 +45,10 @@ import { ApiError } from '@/lib/api'
const routeApi = getRouteApi('/gardens/$gardenId')
+// How many recently-planted chips the Plants-mode quick strip shows — a working
+// set, not the whole history; the picker covers the long tail.
+const RECENT_PLANTS_MAX = 8
+
export function GardenEditorPage() {
const { gardenId } = routeApi.useParams()
const gid = Number(gardenId)
@@ -88,6 +95,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 +117,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, RECENT_PLANTS_MAX)
+ .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 +412,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 +599,7 @@ export function GardenEditorPage() {
{canEdit &&
(focusedObject.plantable ? (
setArmedPlant(null)}
focusedPlopCount={focusedPlops.length}
onClear={() => setClearing(true)}
+ onFill={fillBed}
+ filling={fillObject.isPending}
/>
) : (
Not plantable
@@ -611,6 +641,7 @@ export function GardenEditorPage() {
focusedObject.plantable ? (