Compare commits
9
Commits
a72ddefc99
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cac26286b1 | ||
|
|
c432fe9199 | ||
|
|
f14875557b | ||
|
|
7b150275ae | ||
|
|
9e227e29eb | ||
|
|
256fa4f29f | ||
|
|
7015148edf | ||
|
|
887a3c2cc6 | ||
|
|
ace696467b |
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,7 +160,9 @@ 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 this planting yet.'
|
||||||
|
: scopedObject
|
||||||
? `Nothing written about ${objectDisplayName(scopedObject)} yet.`
|
? `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.'}
|
: '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('')
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user