9 Commits
Author SHA1 Message Date
steve cac26286b1 Merge pull request 'Let plop notes be written from the UI (#85 item 5)' (#123) from feat/plop-journal-notes into main
Build image / build-and-push (push) Successful in 13s
2026-07-23 01:42:02 +00:00
steve c432fe9199 Merge pull request 'Agent: add the corrective tools the toolbox was missing (#85 item 3)' (#122) from feat/agent-corrective-tools into main
Build image / build-and-push (push) Successful in 9s
2026-07-23 01:41:38 +00:00
steveandClaude Opus 4.8 f14875557b Address #123 review: viewers can read plop notes; tighten the API
Build image / build-and-push (push) Successful in 11s
- The "add note" affordance no longer hides from viewers (it claimed
  parity with the bed inspector but gated on !readOnly). A viewer now sees
  "📓 Notes about this plant" and can open the plop's journal to read it;
  the composer stays edit-gated, so they can't write. Real parity now.
- onScopePlantingChange is required, matching onScopeChange (its bed twin),
  so a caller can't pass a plop scope with no way to clear it. Dropped the
  now-dead guard on the "Show all" button.
- Pulled the plop-over-bed scope-label priority into one `scopeLabel`,
  shared by the filter's sibling logic and the composer, so they can't
  drift; trimmed the invariant comment that was restated a third time.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:41:11 -04:00
steveandClaude Opus 4.8 7b150275ae Address #122 review: pageable read_journal, service-clock removal
Build image / build-and-push (push) Successful in 20s
- read_journal now takes an offset, so the hasMore it returns is
  actionable — an agent can page a journal longer than 50 entries.
- remove_planting goes through a new service RemovePlanting that stamps
  removed_at from s.now() (the injectable clock ClearObject and the fill
  path use), instead of the adapter computing the date off the wall clock.
  It delegates to UpdatePlanting, so the role check, version guard and
  history record are unchanged. Drops the now-unused time import.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:39:20 -04:00
steve 9e227e29eb Merge pull request 'Reclaim mobile chrome: drop the editor's top banner, un-cramp the assistant' (#121) from feat/mobile-space-polish into main
Build image / build-and-push (push) Successful in 20s
2026-07-23 01:32:59 +00:00
steveandClaude Opus 4.8 256fa4f29f Address #121 review: keep sign-out reachable, dvh peek, exact height
Build image / build-and-push (push) Successful in 12s
- Fold the account menu into the editor's mobile strip. Hiding the global
  header removed the only sign-out on mobile in the editor; the strip now
  carries it, so the space win stays but sign-out is one tap away.
- EditorRail peek cap vh → dvh, matching the dvh-bounded editor column, so
  it can't overrun the visible viewport and push the mode bar off-screen.
- Mobile editor height 4rem → 3rem: with the header hidden, only <main>'s
  py-6 (3rem) is outside the editor, so 4rem left ~16px dead. Comment
  corrected.
- Trim two comments that duplicated nearby docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:31:35 -04:00
steveandClaude Opus 4.8 7015148edf Let plop notes be written from the UI (#85 item 5)
Build image / build-and-push (push) Successful in 19s
Gadfly review (reusable) / review (pull_request) Successful in 5m29s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5m30s
journal_entries.planting_id was modelled, accepted by the API, and the
JournalPanel already rendered a "planting" badge for such entries — but
nothing in the UI ever created one. A badge for a state the UI couldn't
produce.

Give the plop the same "add note" affordance the bed inspector has:
- PlopInspector gains an onAddNote button ("📓 Add a note about this
  plant"), shown only to an editor (a viewer can't write notes).
- The editor store gains a journalPlantingId scope beside journalObjectId.
  The two are mutually exclusive — each setter clears the other — so the
  journal filter is never double-scoped.
- JournalPanel filters by plantingId when that scope is set, shows a
  "Notes about one planting · Show all" banner, and its composer attaches
  new notes to the plop. Empty-state copy updated to match.

Two taps from a selected plant to typing, mirroring the bed flow. No
backend change — the API already accepted plantingId.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:28:12 -04:00
steveandClaude Opus 4.8 887a3c2cc6 Agent: add the corrective tools the toolbox was missing (#85 item 3)
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m8s
The toolbox could create and move but not delete or resize; write the
journal but not read it; clear a whole bed but not pull one plant; report
seed remaining but not record a purchase. Close those gaps with thin
adapters over the SAME service methods the REST API uses, so they inherit
the permission checks unchanged:

  read_journal    → ListJournal   (the write/read asymmetry, most visible)
  update_object   → UpdateObject  (resize / rotate / rename / plantable)
  delete_object   → DeleteObject  (counterpart to create_object)
  remove_planting → UpdatePlanting (soft-remove ONE plop, like clear does)
  list_seed_lots  → ListSeedLots
  record_seed_lot → CreateSeedLot (record a purchase; "I bought 2 packets")

To address a single plop the agent needs its id + version, so
DescribePlanting now carries both — the same way DescribeObject.Version
already lets it edit an object. remove_planting soft-removes (removed_at =
today), mirroring clear_object, so the plant stays in planting history and
the change is undoable.

Deferred deliberately: an undo/revert tool needs a way to list recent
change sets to get a changeSetId, which is a larger addition; noted on the
issue for a follow-up.

Tested through the tool layer (TestCorrectiveTools): resize, single-plop
removal, journal read-back, seed-lot record+list, and delete.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:23:46 -04:00
steveandClaude Opus 4.8 ace696467b Reclaim mobile chrome: drop the editor's top banner, un-cramp the assistant
Build image / build-and-push (push) Successful in 23s
Gadfly review (reusable) / review (pull_request) Successful in 11m12s
Adversarial Review (Gadfly) / review (pull_request) Successful in 11m12s
Two mobile complaints, both in the editor/assistant context:

- The global top bar (brand + account) sat above the editor's own
  garden-name strip — a whole banner of pure chrome over a full-screen
  canvas. Hide it on mobile in the editor (the same rationale that hides
  the bottom nav there) and fold a leaf/back affordance into the garden
  strip so there's still a way out. Desktop keeps the header. The editor's
  height band shrinks 8rem → 4rem on mobile to hand that space to the
  canvas; desktop stays 8rem since the header is still there.

- The assistant (and journal/history) rendered inside the rail peek
  capped at max-h-[50vh]; after the tab bar, header and input, messages
  got ~200px. That cap is right for the inspector (read alongside the
  canvas) but not for a mode where reading/typing is the task. Panel
  modes now take a taller slice (78vh) via a `tall` prop; the inspector
  keeps the 50vh peek. Chat message spacing loosened gap-2 → gap-3.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:13:35 -04:00
11 changed files with 381 additions and 22 deletions
+111
View File
@@ -64,6 +64,40 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
"(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+ "(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+
"thing is. observedAt defaults to today; set it to backdate.", "thing is. observedAt defaults to today; set it to backdate.",
a.addJournalEntry), a.addJournalEntry),
llm.DefineTool("read_journal",
"Read back the garden's grow journal — the observations add_journal_entry wrote. "+
"Narrow it with objectId (one bed), or a from/to date range (YYYY-MM-DD). Most "+
"recently observed first. Use this to answer \"what did I note about the west bed?\" "+
"or \"what happened last spring?\".",
a.readJournal),
llm.DefineTool("update_object",
"Change an existing object: resize it (widthCm/heightCm), rotate it (rotationDeg), "+
"rename it (name), or toggle whether it can hold plants (plantable). Only the fields "+
"you pass change. Needs the object's current version from describe_garden. Example: "+
"\"make that bed 60cm wider\" — read its widthCm from describe_garden, add 60, pass the "+
"sum.",
a.updateObject),
llm.DefineTool("delete_object",
"Delete an object from a garden entirely, along with its plantings. This is the "+
"counterpart to create_object — use it for \"remove the old grow bag\". Permanent (not "+
"the same as clearing a bed's plants); prefer clear_object when the bed itself stays.",
a.deleteObject),
llm.DefineTool("remove_planting",
"Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+
"all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+
"bed does. Needs the plop's id and version from describe_garden. Use for \"pull the "+
"basil out of the corner\".",
a.removePlanting),
llm.DefineTool("list_seed_lots",
"List the seed lots (purchases) the user has recorded — vendor, quantity, and what's "+
"left — optionally for one plant via plantId. This is the detail behind the \"seed "+
"remaining\" number find_plant reports.",
a.listSeedLots),
llm.DefineTool("record_seed_lot",
"Record a seed purchase for a plant the user owns, so pansy can track how much is left. "+
"Get the plantId from find_plant first. quantity + unit is what was bought (e.g. 2 "+
"\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\".",
a.recordSeedLot),
) )
} }
@@ -177,3 +211,80 @@ func (a *adapter) clearObject(ctx context.Context, args struct {
} }
return map[string]int{"cleared": n}, nil return map[string]int{"cleared": n}, nil
} }
func (a *adapter) readJournal(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden whose journal to read"`
ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"`
From string `json:"from" description:"optional earliest observed date, YYYY-MM-DD"`
To string `json:"to" description:"optional latest observed date, YYYY-MM-DD"`
Offset int `json:"offset" description:"how many entries to skip; pass the count you've already seen to page when hasMore is true"`
}) (any, error) {
q := service.JournalQuery{ObjectID: args.ObjectID, Limit: 50, Offset: args.Offset}
if args.From != "" {
q.From = &args.From
}
if args.To != "" {
q.To = &args.To
}
entries, hasMore, err := a.svc.ListJournal(ctx, a.actor, args.GardenID, q)
if err != nil {
return nil, err
}
// hasMore is actionable now: re-call with offset += len(entries) to page.
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
}
func (a *adapter) updateObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to change"`
Version int64 `json:"version" description:"the object's current version (from describe_garden)"`
Name *string `json:"name" description:"optional new label"`
WidthCM *float64 `json:"widthCm" description:"optional new width in cm (a circle's diameter)"`
HeightCM *float64 `json:"heightCm" description:"optional new height in cm"`
RotationDeg *float64 `json:"rotationDeg" description:"optional new rotation in degrees"`
Plantable *bool `json:"plantable" description:"optional: whether the object can hold plants"`
}) (any, error) {
return a.svc.UpdateObject(ctx, a.actor, args.ObjectID, service.ObjectPatch{
Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM,
RotationDeg: args.RotationDeg, Plantable: args.Plantable,
}, args.Version)
}
func (a *adapter) deleteObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to delete (with its plantings)"`
}) (any, error) {
if err := a.svc.DeleteObject(ctx, a.actor, args.ObjectID); err != nil {
return nil, err
}
return map[string]any{"deleted": args.ObjectID}, nil
}
func (a *adapter) removePlanting(ctx context.Context, args struct {
PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"`
Version int64 `json:"version" description:"the plop's current version (from describe_garden)"`
}) (any, error) {
// Soft-remove via the service, so removed_at is stamped from the same
// (injectable) clock clear_object uses rather than the adapter's wall clock.
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version)
}
func (a *adapter) listSeedLots(ctx context.Context, args struct {
PlantID *int64 `json:"plantId" description:"optional: only lots for this plant"`
}) (any, error) {
return a.svc.ListSeedLots(ctx, a.actor, args.PlantID)
}
func (a *adapter) recordSeedLot(ctx context.Context, args struct {
PlantID int64 `json:"plantId" description:"plant the seed is for (from find_plant); must be the user's own or a built-in"`
Quantity float64 `json:"quantity" description:"how much was bought, in the given unit"`
Unit string `json:"unit" description:"what quantity counts, e.g. packets | seeds | grams"`
Vendor string `json:"vendor" description:"optional vendor name"`
SourceURL string `json:"sourceUrl" description:"optional http(s) link to where it was bought"`
PackedForYear *int `json:"packedForYear" description:"optional 'packed for' year from the packet"`
Notes string `json:"notes" description:"optional free-text notes"`
}) (any, error) {
return a.svc.CreateSeedLot(ctx, a.actor, service.SeedLotInput{
PlantID: args.PlantID, Quantity: args.Quantity, Unit: args.Unit,
Vendor: args.Vendor, SourceURL: args.SourceURL,
PackedForYear: args.PackedForYear, Notes: args.Notes,
})
}
+121
View File
@@ -347,6 +347,127 @@ func TestJournalToolWritesADatedObservation(t *testing.T) {
} }
} }
// TestCorrectiveTools covers the #85 gaps: the agent can now read the journal it
// could only write, resize and delete an object it could only create and move,
// pull a single plop instead of clearing the whole bed, and record/read seed
// lots. Each is driven through the tool layer the way a model would run it.
func TestCorrectiveTools(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
box := NewToolbox(svc, owner)
var gid int64 // set once the garden exists; the describe closure reads it.
call := func(name string, args any) llm.ToolResult {
t.Helper()
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
}
describe := func() service.DescribeResult {
t.Helper()
res := call("describe_garden", map[string]any{"gardenId": gid})
if res.IsError {
t.Fatalf("describe_garden: %s", res.Content)
}
var d service.DescribeResult
if err := json.Unmarshal([]byte(res.Content), &d); err != nil {
t.Fatalf("decode describe: %v", err)
}
return d
}
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
gid = g.ID
basil := mustPlant(t, svc, owner, "Basil", 25, "🌿")
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
// update_object: "make that bed 100cm wider" — read the version, pass a new width.
d := describe()
if r := call("update_object", map[string]any{
"objectId": bed.ID, "version": d.Objects[0].Version, "widthCm": 500.0,
}); r.IsError {
t.Fatalf("update_object: %s", r.Content)
}
if w := describe().Objects[0].WidthCM; w != 500 {
t.Errorf("width = %v after update_object, want 500", w)
}
// place a plop, then remove_planting it by id+version — one plop, not the bed.
if r := call("place_planting", map[string]any{
"objectId": bed.ID, "plantId": basil.ID, "xCm": 0, "yCm": 0, "radiusCm": 30,
}); r.IsError {
t.Fatalf("place_planting: %s", r.Content)
}
d = describe()
if len(d.Objects[0].Plantings) != 1 {
t.Fatalf("want 1 plop before removal, got %d", len(d.Objects[0].Plantings))
}
plop := d.Objects[0].Plantings[0]
if r := call("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}); r.IsError {
t.Fatalf("remove_planting: %s", r.Content)
}
if n := len(describe().Objects[0].Plantings); n != 0 {
t.Errorf("want 0 active plops after remove_planting, got %d", n)
}
// add then read the journal — the write/read asymmetry the issue flagged.
if r := call("add_journal_entry", map[string]any{
"gardenId": g.ID, "objectId": bed.ID, "body": "aphids", "observedAt": "2026-06-01",
}); r.IsError {
t.Fatalf("add_journal_entry: %s", r.Content)
}
res := call("read_journal", map[string]any{"gardenId": g.ID, "objectId": bed.ID})
if res.IsError {
t.Fatalf("read_journal: %s", res.Content)
}
var jr struct {
Entries []struct {
Body string `json:"body"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(res.Content), &jr); err != nil {
t.Fatalf("decode read_journal: %v (%s)", err, res.Content)
}
if len(jr.Entries) != 1 || jr.Entries[0].Body != "aphids" {
t.Errorf("read_journal = %+v, want the one aphids entry", jr.Entries)
}
// record then list a seed lot — the detail behind find_plant's "remaining".
if r := call("record_seed_lot", map[string]any{
"plantId": basil.ID, "quantity": 2.0, "unit": "packets", "vendor": "Johnny's",
}); r.IsError {
t.Fatalf("record_seed_lot: %s", r.Content)
}
res = call("list_seed_lots", map[string]any{"plantId": basil.ID})
if res.IsError {
t.Fatalf("list_seed_lots: %s", res.Content)
}
var lots []struct {
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
}
if err := json.Unmarshal([]byte(res.Content), &lots); err != nil {
t.Fatalf("decode list_seed_lots: %v (%s)", err, res.Content)
}
if len(lots) != 1 || lots[0].Quantity != 2 || lots[0].Unit != "packets" {
t.Errorf("list_seed_lots = %+v, want one lot of 2 packets", lots)
}
// delete_object: the counterpart to create_object.
if r := call("delete_object", map[string]any{"objectId": bed.ID}); r.IsError {
t.Fatalf("delete_object: %s", r.Content)
}
if n := len(describe().Objects); n != 0 {
t.Errorf("want 0 objects after delete_object, got %d", n)
}
}
// newAgentTestService spins up an in-memory pansy with one registered user. // newAgentTestService spins up an in-memory pansy with one registered user.
func newAgentTestService(t *testing.T) (*service.Service, int64) { func newAgentTestService(t *testing.T) (*service.Service, int64) {
t.Helper() t.Helper()
+6
View File
@@ -471,7 +471,11 @@ type DescribeObject struct {
} }
// DescribePlanting is one plop with a rough compass location, for DescribeResult. // DescribePlanting is one plop with a rough compass location, for DescribeResult.
// ID + Version are included so an agent can address a single plop — remove it or
// move it — the same way DescribeObject.Version lets it edit an object.
type DescribePlanting struct { type DescribePlanting struct {
ID int64 `json:"id"`
Version int64 `json:"version"`
PlantID int64 `json:"plantId"` PlantID int64 `json:"plantId"`
Plant string `json:"plant"` Plant string `json:"plant"`
Count int `json:"count"` Count int `json:"count"`
@@ -518,6 +522,8 @@ func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (
count = *pl.Count count = *pl.Count
} }
do.Plantings = append(do.Plantings, DescribePlanting{ do.Plantings = append(do.Plantings, DescribePlanting{
ID: pl.ID,
Version: pl.Version,
PlantID: pl.PlantID, PlantID: pl.PlantID,
Plant: plantByID[pl.PlantID].Name, Plant: plantByID[pl.PlantID].Name,
Count: count, Count: count,
+11
View File
@@ -178,6 +178,17 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
return updated, nil return updated, nil
} }
// RemovePlanting soft-removes a single plop — the one-plop counterpart to
// ClearObject, used by the agent's remove_planting tool. It stamps removed_at
// from the service clock (s.now()), same as ClearObject and the fill path, so the
// removal date can't diverge by which caller set it; then delegates to
// UpdatePlanting for the editor-role check, version guard and history record.
func (s *Service) RemovePlanting(ctx context.Context, actorID, plantingID, version int64) (*domain.Planting, error) {
today := s.now().UTC().Format(dateLayout)
return s.UpdatePlanting(ctx, actorID, plantingID,
PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, version)
}
// plantingEditSummary describes a plop edit for the history list. Soft-removal // plantingEditSummary describes a plop edit for the history list. Soft-removal
// ("clear bed", harvested) is the one edit worth naming specifically — it reads // ("clear bed", harvested) is the one edit worth naming specifically — it reads
// as a removal to the person who did it, not as an edit. // as a removal to the person who did it, not as an edit.
+15 -3
View File
@@ -43,12 +43,22 @@ export function AppShell() {
// max-w-5xl reading measure the other pages use (#107). // max-w-5xl reading measure the other pages use (#107).
const canvasRoute = inEditor || inPublicGarden const canvasRoute = inEditor || inPublicGarden
const showBottomNav = !!user && !canvasRoute const showBottomNav = !!user && !canvasRoute
// On a phone the editor is a full-screen canvas, so the global top bar is pure
// chrome above the garden — hide it and let the editor's own strip carry the
// back link AND the account menu (so sign-out isn't lost). Editor only, not the
// public view, which has no strip of its own to fall back on.
const hideHeaderOnMobile = inEditor
const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin) const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin)
return ( return (
<div className="flex min-h-full flex-col"> <div className="flex min-h-full flex-col">
<header className="sticky top-0 z-20 border-b border-border bg-surface/90 backdrop-blur"> <header
className={cn(
'sticky top-0 z-20 border-b border-border bg-surface/90 backdrop-blur',
hideHeaderOnMobile && 'hidden md:block',
)}
>
{/* The bar matches the content width below: constrained on reading pages, {/* The bar matches the content width below: constrained on reading pages,
edge-to-edge on the canvas routes so the brand aligns with the editor. */} edge-to-edge on the canvas routes so the brand aligns with the editor. */}
<nav className={cn('flex items-center gap-4 px-4 py-3', canvasRoute ? '' : 'mx-auto max-w-5xl')}> <nav className={cn('flex items-center gap-4 px-4 py-3', canvasRoute ? '' : 'mx-auto max-w-5xl')}>
@@ -115,8 +125,10 @@ export function AppShell() {
} }
/** Account control: a compact button that toggles a small sign-out popover. On /** Account control: a compact button that toggles a small sign-out popover. On
* desktop the display name shows inline; on mobile it lives inside the popover. */ * desktop the display name shows inline; on mobile it lives inside the popover.
function AccountMenu({ displayName }: { displayName: string }) { * Exported so the editor's mobile strip can carry it — the global header that
* normally hosts it is hidden there (see hideHeaderOnMobile). */
export function AccountMenu({ displayName }: { displayName: string }) {
const logout = useLogout() const logout = useLogout()
const navigate = useNavigate() const navigate = useNavigate()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
+1 -1
View File
@@ -143,7 +143,7 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
</p> </p>
)} )}
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
{history.isPending && <p className="text-sm text-muted">Loading the conversation…</p>} {history.isPending && <p className="text-sm text-muted">Loading the conversation…</p>}
{/* A failed load rendering as an empty thread would look like the {/* A failed load rendering as an empty thread would look like the
conversation had been lost, which is a much worse thing to believe. */} conversation had been lost, which is a much worse thing to believe. */}
+16 -6
View File
@@ -18,10 +18,13 @@ import { cn } from '@/lib/cn'
* closes completely when nothing needs it. * closes completely when nothing needs it.
* *
* Layout differs by breakpoint. Desktop: a fixed 20rem column beside the canvas. * Layout differs by breakpoint. Desktop: a fixed 20rem column beside the canvas.
* Phone: an in-flow PEEK (#101) — a ≤50vh panel the editor's flex column places * Phone: an in-flow PEEK (#101) — a capped-height panel the editor's flex column
* BETWEEN the canvas and the always-visible mode bar, so the canvas shrinks to * places BETWEEN the canvas and the always-visible mode bar, so the canvas
* keep the garden visible above it and the mode bar reachable below, rather than * shrinks to keep the garden visible above it and the mode bar reachable below,
* a bottom sheet that covered the whole garden. * rather than a bottom sheet that covered the whole garden. `tall` raises that
* cap for panel modes (journal/history/assistant), where reading and typing are
* the task and a half-height peek felt cramped; the inspector keeps the shorter
* peek so the canvas it describes stays in view.
*/ */
export interface RailTab { export interface RailTab {
@@ -38,11 +41,14 @@ export function EditorRail({
activeId, activeId,
onActivate, onActivate,
onClose, onClose,
tall = false,
}: { }: {
tabs: RailTab[] tabs: RailTab[]
activeId: string activeId: string
onActivate: (id: string) => void onActivate: (id: string) => void
onClose: () => void onClose: () => void
/** Raise the mobile peek's height cap (panel modes want the room). */
tall?: boolean
}) { }) {
const active = tabs.find((t) => t.id === activeId) ?? tabs[0] const active = tabs.find((t) => t.id === activeId) ?? tabs[0]
if (!active) return null if (!active) return null
@@ -54,8 +60,12 @@ export function EditorRail({
// canvas and the always-visible mode bar (the editor's flex column places // canvas and the always-visible mode bar (the editor's flex column places
// it there), so the garden stays visible above it and the mode bar stays // it there), so the garden stays visible above it and the mode bar stays
// reachable below. The canvas flexes to fill whatever's left. Desktop: a // reachable below. The canvas flexes to fill whatever's left. Desktop: a
// fixed-width column beside the canvas. // fixed-width column beside the canvas (the cap doesn't apply there).
'flex max-h-[50vh] min-h-0 shrink-0 flex-col rounded-t-xl border-t border-border bg-surface shadow-lg', 'flex min-h-0 shrink-0 flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
// dvh, not vh: the enclosing editor column is dvh-bounded, and on mobile
// Safari/Chrome vh is the *largest* viewport, so a vh cap could overrun the
// visible area and shove the mode bar off-screen (same #85 reasoning).
tall ? 'max-h-[78dvh]' : 'max-h-[50dvh]',
'md:static md:max-h-none md:w-80 md:rounded-xl md:border md:shadow-sm', 'md:static md:max-h-none md:w-80 md:rounded-xl md:border md:shadow-sm',
)} )}
> >
+40 -8
View File
@@ -37,6 +37,8 @@ export function JournalPanel({
objects, objects,
scopeObjectId, scopeObjectId,
onScopeChange, onScopeChange,
scopePlantingId,
onScopePlantingChange,
}: { }: {
gardenId: number gardenId: number
canEdit: boolean canEdit: boolean
@@ -46,19 +48,30 @@ export function JournalPanel({
/** Which bed the panel is filtered to, if any. */ /** Which bed the panel is filtered to, if any. */
scopeObjectId: number | null scopeObjectId: number | null
onScopeChange: (id: number | null) => void onScopeChange: (id: number | null) => void
/** Which single plop the panel is filtered to, if any (#85). The store keeps
* this mutually exclusive with scopeObjectId. Required like its bed twin. */
scopePlantingId: number | null
onScopePlantingChange: (id: number | null) => void
}) { }) {
// Date-range narrowing (#85): the backend and JournalFilter already supported // Date-range narrowing (#85): the backend and JournalFilter already supported
// from/to; they just had no UI. Empty inputs don't filter. // from/to; they just had no UI. Empty inputs don't filter.
const [from, setFrom] = useState('') const [from, setFrom] = useState('')
const [to, setTo] = useState('') const [to, setTo] = useState('')
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
// One source of scope priority — plop over bed — for both the filter and the
// composer's label, so they can't drift.
const scopeLabel = scopePlantingId != null ? 'this planting' : scopedObject ? objectDisplayName(scopedObject) : null
const filter = { const filter = {
...(scopeObjectId != null ? { objectId: scopeObjectId } : {}), ...(scopePlantingId != null
? { plantingId: scopePlantingId }
: scopeObjectId != null
? { objectId: scopeObjectId }
: {}),
...(from ? { from } : {}), ...(from ? { from } : {}),
...(to ? { to } : {}), ...(to ? { to } : {}),
} }
const journal = useJournal(gardenId, filter) const journal = useJournal(gardenId, filter)
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? [] const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -83,6 +96,19 @@ export function JournalPanel({
)} )}
</div> </div>
{scopePlantingId != null && (
<div className="flex items-center justify-between gap-2 rounded-md bg-accent/10 px-2 py-1 text-xs">
<span className="text-accent-strong">Notes about one planting</span>
<button
type="button"
onClick={() => onScopePlantingChange(null)}
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
Show all
</button>
</div>
)}
<div className="flex items-center gap-2 text-xs text-muted"> <div className="flex items-center gap-2 text-xs text-muted">
<label className="flex items-center gap-1"> <label className="flex items-center gap-1">
<span>From</span> <span>From</span>
@@ -121,8 +147,9 @@ export function JournalPanel({
{canEdit && ( {canEdit && (
<Composer <Composer
gardenId={gardenId} gardenId={gardenId}
objectId={scopeObjectId} objectId={scopePlantingId != null ? null : scopeObjectId}
scopeLabel={scopedObject ? objectDisplayName(scopedObject) : null} plantingId={scopePlantingId}
scopeLabel={scopeLabel}
/> />
)} )}
@@ -133,9 +160,11 @@ export function JournalPanel({
{journal.isSuccess && entries.length === 0 && ( {journal.isSuccess && entries.length === 0 && (
<p className="text-sm text-muted"> <p className="text-sm text-muted">
{scopedObject {scopePlantingId != null
? `Nothing written about ${objectDisplayName(scopedObject)} yet.` ? 'Nothing written about this planting yet.'
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'} : scopedObject
? `Nothing written about ${objectDisplayName(scopedObject)} yet.`
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'}
</p> </p>
)} )}
@@ -174,10 +203,13 @@ export function JournalPanel({
function Composer({ function Composer({
gardenId, gardenId,
objectId, objectId,
plantingId = null,
scopeLabel, scopeLabel,
}: { }: {
gardenId: number gardenId: number
objectId: number | null objectId: number | null
/** When set, the note attaches to this plop rather than a bed (#85). */
plantingId?: number | null
scopeLabel: string | null scopeLabel: string | null
}) { }) {
const create = useCreateJournalEntry(gardenId) const create = useCreateJournalEntry(gardenId)
@@ -191,7 +223,7 @@ function Composer({
if (!text) return if (!text) return
setError(null) setError(null)
create.mutate( create.mutate(
{ body: text, observedAt, objectId: objectId ?? undefined }, { body: text, observedAt, objectId: objectId ?? undefined, plantingId: plantingId ?? undefined },
{ {
onSuccess: () => { onSuccess: () => {
setBody('') setBody('')
+12
View File
@@ -25,6 +25,7 @@ export function PlopInspector({
unit, unit,
onChangePlant, onChangePlant,
onClose, onClose,
onAddNote,
readOnly = false, readOnly = false,
}: { }: {
plop: EditorPlanting plop: EditorPlanting
@@ -33,6 +34,11 @@ export function PlopInspector({
unit: UnitPref unit: UnitPref
onChangePlant: () => void onChangePlant: () => void
onClose: () => void onClose: () => void
/** Scope the journal to this plop and open it — the plop parallel of the bed
* inspector's "add note" (#85). Offered to viewers too (to READ the plop's
* notes, like the bed inspector does); the journal's composer is separately
* gated on edit rights, so a viewer just sees the entries. */
onAddNote?: () => void
readOnly?: boolean readOnly?: boolean
}) { }) {
const update = useUpdatePlanting(gardenId) const update = useUpdatePlanting(gardenId)
@@ -172,6 +178,12 @@ export function PlopInspector({
/> />
</fieldset> </fieldset>
{onAddNote && (
<Button variant="ghost" className="justify-start px-2 py-1 text-sm" onClick={onAddNote}>
📓 {readOnly ? 'Notes about this plant' : 'Add a note about this plant'}
</Button>
)}
{!readOnly && ( {!readOnly && (
<Button <Button
variant="ghost" variant="ghost"
+12 -1
View File
@@ -59,6 +59,12 @@ interface EditorState {
journalObjectId: number | null journalObjectId: number | null
setJournalObjectId: (id: number | null) => void setJournalObjectId: (id: number | null) => void
// Which single plop the journal is filtered to, if any — the parallel of
// journalObjectId for a planting (#85). The two scopes are mutually exclusive
// (the setters clear each other), so the journal filter is never ambiguous.
journalPlantingId: number | null
setJournalPlantingId: (id: number | null) => void
// The plant armed for placing plops (set after the PlantPicker choice); stays // The plant armed for placing plops (set after the PlantPicker choice); stays
// armed for repeat-placement until cleared (Escape / done). null = not placing. // armed for repeat-placement until cleared (Escape / done). null = not placing.
armedPlant: Plant | null armedPlant: Plant | null
@@ -116,7 +122,11 @@ export const useEditorStore = create<EditorState>((set) => ({
setSeasonYear: (year) => set({ seasonYear: year }), setSeasonYear: (year) => set({ seasonYear: year }),
journalObjectId: null, journalObjectId: null,
setJournalObjectId: (id) => set({ journalObjectId: id }), // Scoping to a bed clears any plop scope, so only one is ever active.
setJournalObjectId: (id) => set({ journalObjectId: id, journalPlantingId: null }),
journalPlantingId: null,
setJournalPlantingId: (id) => set({ journalPlantingId: id, journalObjectId: null }),
armedPlant: null, armedPlant: null,
armedLotId: null, armedLotId: null,
@@ -147,6 +157,7 @@ export const useEditorStore = create<EditorState>((set) => ({
railTab: null, railTab: null,
seasonYear: null, seasonYear: null,
journalObjectId: null, journalObjectId: null,
journalPlantingId: null,
mode: DEFAULT_MODE, mode: DEFAULT_MODE,
}), }),
})) }))
+36 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getRouteApi } from '@tanstack/react-router' import { Link, getRouteApi } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert' import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { GardenCanvas } from '@/editor/GardenCanvas' import { GardenCanvas } from '@/editor/GardenCanvas'
@@ -23,6 +23,7 @@ import { isCoarsePointer } from '@/editor/shared'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import type { EditorGarden } from '@/editor/types' import type { EditorGarden } from '@/editor/types'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal' import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
import { AccountMenu } from '@/components/layout/AppShell'
import { useMe } from '@/lib/auth' import { useMe } from '@/lib/auth'
import { import {
toEditorObject, toEditorObject,
@@ -85,6 +86,8 @@ export function GardenEditorPage() {
const setRailTab = useEditorStore((s) => s.setRailTab) const setRailTab = useEditorStore((s) => s.setRailTab)
const journalObjectId = useEditorStore((s) => s.journalObjectId) const journalObjectId = useEditorStore((s) => s.journalObjectId)
const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId) const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId)
const journalPlantingId = useEditorStore((s) => s.journalPlantingId)
const setJournalPlantingId = useEditorStore((s) => s.setJournalPlantingId)
const journalCounts = useJournalCounts(gid) const journalCounts = useJournalCounts(gid)
const capabilities = useCapabilities() const capabilities = useCapabilities()
const journalTotal = useMemo( const journalTotal = useMemo(
@@ -501,6 +504,13 @@ export function GardenEditorPage() {
readOnly={!canEdit} readOnly={!canEdit}
onChangePlant={() => setPicker('change')} onChangePlant={() => setPicker('change')}
onClose={() => selectPlanting(null)} onClose={() => selectPlanting(null)}
onAddNote={() => {
// Parity with the bed inspector: scope the journal to this plop and
// open it. The plop stays selected, so the selection effect keeps the
// inspector reachable when you switch back.
setJournalPlantingId(selectedPlop.id)
setRailTab('journal')
}}
/> />
) : ( ) : (
<p className="text-sm text-muted">Select a bed or a planting to edit it.</p> <p className="text-sm text-muted">Select a bed or a planting to edit it.</p>
@@ -521,6 +531,8 @@ export function GardenEditorPage() {
objects={objects} objects={objects}
scopeObjectId={journalObjectId} scopeObjectId={journalObjectId}
onScopeChange={setJournalObjectId} onScopeChange={setJournalObjectId}
scopePlantingId={journalPlantingId}
onScopePlantingChange={setJournalPlantingId}
/> />
), ),
}, },
@@ -544,8 +556,13 @@ export function GardenEditorPage() {
// 100dvh, not 100vh: on mobile Safari/Chrome 100vh is the *largest* viewport // 100dvh, not 100vh: on mobile Safari/Chrome 100vh is the *largest* viewport
// (URL bar hidden), so with the bar showing the editor overflowed and pushed // (URL bar hidden), so with the bar showing the editor overflowed and pushed
// the canvas bottom + Fit button under the browser chrome (#85). // the canvas bottom + Fit button under the browser chrome (#85).
//
// The subtracted band differs by breakpoint because the chrome does. On mobile
// the global top bar is hidden here (AppShell), so the only thing outside the
// editor is <main>'s py-6 — 3rem, top + bottom. On desktop the header is
// present, so keep the original 8rem.
return ( return (
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row"> <div className="flex h-[calc(100dvh-3rem)] flex-col gap-3 md:h-[calc(100dvh-8rem)] md:flex-row">
{/* Desktop-only control column. On mobile these move to the bottom mode bar {/* Desktop-only control column. On mobile these move to the bottom mode bar
+ a slim top strip so the canvas — the point of the screen — isn't shoved + a slim top strip so the canvas — the point of the screen — isn't shoved
into a corner by a stack of controls (#99). */} into a corner by a stack of controls (#99). */}
@@ -597,8 +614,17 @@ export function GardenEditorPage() {
<div className="relative flex min-h-0 flex-1 flex-col gap-2"> <div className="relative flex min-h-0 flex-1 flex-col gap-2">
{/* Mobile top strip: the garden identity / season / share that live in the {/* Mobile top strip: the garden identity / season / share that live in the
desktop left column. md:hidden. */} desktop left column, plus the way out. The global header is hidden on
mobile here (AppShell), so this leaf is the only route back to the
gardens list — it can't be dropped. md:hidden. */}
<div className="flex items-center gap-2 md:hidden"> <div className="flex items-center gap-2 md:hidden">
<Link
to="/gardens"
aria-label="All gardens"
className="-ml-1 shrink-0 rounded-md px-1.5 py-1 text-lg leading-none text-accent-strong outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
🌱
</Link>
<h1 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight" title={garden.name}> <h1 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight" title={garden.name}>
{garden.name} {garden.name}
</h1> </h1>
@@ -613,6 +639,10 @@ export function GardenEditorPage() {
Share Share
</Button> </Button>
)} )}
{/* The global header (and its account menu) is hidden on mobile in the
editor, so carry sign-out here — otherwise it's unreachable without
leaving the garden. */}
{me.data && <AccountMenu displayName={me.data.displayName} />}
</div> </div>
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />} {seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />}
{/* Focus toolbar is desktop-only; on mobile its plant tools move to the {/* Focus toolbar is desktop-only; on mobile its plant tools move to the
@@ -673,6 +703,9 @@ export function GardenEditorPage() {
tabs={railTabs} tabs={railTabs}
activeId={railTab} activeId={railTab}
onActivate={setRailTab} onActivate={setRailTab}
// Panel modes want the room; the inspector stays a shorter peek (see the
// `tall` prop doc).
tall={railTab !== 'inspector'}
onClose={() => { onClose={() => {
// Only the inspector is *about* the selection, so only closing it // Only the inspector is *about* the selection, so only closing it
// deselects; dismissing a panel leaves the canvas as you had it. Any // deselects; dismissing a panel leaves the canvas as you had it. Any