Compare commits
15
Commits
06b887f58e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cac26286b1 | ||
|
|
c432fe9199 | ||
|
|
f14875557b | ||
|
|
7b150275ae | ||
|
|
9e227e29eb | ||
|
|
256fa4f29f | ||
|
|
7015148edf | ||
|
|
887a3c2cc6 | ||
|
|
ace696467b | ||
|
|
a72ddefc99 | ||
|
|
cf37e57808 | ||
|
|
08d8c5e47d | ||
|
|
e7b91de752 | ||
|
|
b0e11bce17 | ||
|
|
1323a03acd |
@@ -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.
|
||||||
|
|||||||
Generated
+1469
-5
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,8 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^9.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Modal } from '@/components/ui/Modal'
|
||||||
|
import { Select } from '@/components/ui/Select'
|
||||||
|
import { TextField } from '@/components/ui/TextField'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import {
|
||||||
|
lotDefaults,
|
||||||
|
newPlantDefaults,
|
||||||
|
useCreateFromPacket,
|
||||||
|
useScanPacket,
|
||||||
|
type PacketProposal,
|
||||||
|
} from '@/lib/seedPacket'
|
||||||
|
import {
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
PLANT_CATEGORIES,
|
||||||
|
type PlantCategory,
|
||||||
|
type PlantInput,
|
||||||
|
} from '@/lib/plants'
|
||||||
|
import { LOT_UNITS, type LotUnit } from '@/lib/seedLots'
|
||||||
|
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||||
|
|
||||||
|
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
|
||||||
|
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
|
||||||
|
|
||||||
|
// 'new' is "create a new variety"; a number selects that existing candidate plant.
|
||||||
|
type Selection = number | 'new'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Photograph a seed packet → an editable proposal → confirm into a plant + lot
|
||||||
|
* (#102). Two phases in one dialog: capture (camera/upload) then review. The
|
||||||
|
* review never commits blind — the model can misread, so every committed field is
|
||||||
|
* editable and the human picks "this is an existing plant" vs "a new variety".
|
||||||
|
*
|
||||||
|
* Only offered where `capabilities.vision` is on (the caller gates the entry
|
||||||
|
* point), so a scan should always be possible; a 503 is still handled in case the
|
||||||
|
* model is torn down between the capabilities poll and the upload.
|
||||||
|
*/
|
||||||
|
export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: () => void }) {
|
||||||
|
const scan = useScanPacket()
|
||||||
|
const create = useCreateFromPacket()
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null)
|
||||||
|
// Lets Cancel abort a slow/hung scan (the server allows up to 120s) so the
|
||||||
|
// dialog is never a trap the user can only escape by reloading the page.
|
||||||
|
const scanAbort = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
const [proposal, setProposal] = useState<PacketProposal | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Review-phase fields, seeded from the proposal when a scan lands.
|
||||||
|
const [selection, setSelection] = useState<Selection>('new')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [category, setCategory] = useState<PlantCategory>('vegetable')
|
||||||
|
const [spacing, setSpacing] = useState('')
|
||||||
|
const [days, setDays] = useState('')
|
||||||
|
// One vendor field: it's the packet's vendor, and feeds both the new plant (if
|
||||||
|
// creating one) and the lot.
|
||||||
|
const [vendor, setVendor] = useState('')
|
||||||
|
const [quantity, setQuantity] = useState('')
|
||||||
|
const [lotUnit, setLotUnit] = useState<LotUnit>('packets')
|
||||||
|
const [sku, setSku] = useState('')
|
||||||
|
const [lotCode, setLotCode] = useState('')
|
||||||
|
const [packedForYear, setPackedForYear] = useState('')
|
||||||
|
const [cost, setCost] = useState('')
|
||||||
|
|
||||||
|
const unitLabel = spacingUnitLabel(unit)
|
||||||
|
const busy = scan.isPending || create.isPending
|
||||||
|
|
||||||
|
function onFile(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
// Reset the input so re-picking the same file fires change again (e.g. after
|
||||||
|
// an error, retrying the same photo).
|
||||||
|
e.target.value = ''
|
||||||
|
if (!file) return
|
||||||
|
setError(null)
|
||||||
|
const controller = new AbortController()
|
||||||
|
scanAbort.current = controller
|
||||||
|
scan.mutate(
|
||||||
|
{ file, signal: controller.signal },
|
||||||
|
{
|
||||||
|
onSuccess: (p) => {
|
||||||
|
const plant = newPlantDefaults(p)
|
||||||
|
const lot = lotDefaults(p.packet)
|
||||||
|
setProposal(p)
|
||||||
|
// Default to the top candidate when there is one — the likely case is
|
||||||
|
// the packet is a variety already in the catalog — else create a new one.
|
||||||
|
setSelection(p.candidates[0]?.plant.id ?? 'new')
|
||||||
|
setName(plant.name)
|
||||||
|
setCategory(plant.category)
|
||||||
|
setSpacing(String(spacingFromCm(plant.spacingCm, unit)))
|
||||||
|
setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '')
|
||||||
|
setVendor(lot.vendor)
|
||||||
|
setQuantity(String(lot.quantity))
|
||||||
|
setLotUnit(lot.unit)
|
||||||
|
setSku(lot.sku)
|
||||||
|
setLotCode(lot.lotCode)
|
||||||
|
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
|
||||||
|
// Cost isn't on a packet, so it's the one field not reseeded from the
|
||||||
|
// proposal; clear it so a value typed before a Rescan doesn't linger.
|
||||||
|
setCost('')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
// An aborted scan is a user cancel, not a failure — and Cancel also
|
||||||
|
// closes the dialog, so there's nothing to report.
|
||||||
|
if ((err as Error)?.name === 'AbortError') return
|
||||||
|
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet."))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirm(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!proposal) return
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Lot validation mirrors SeedLotModal so the two paths accept the same things.
|
||||||
|
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||||
|
if (!Number.isFinite(qty) || qty < 0) {
|
||||||
|
setError('Quantity must be a number, or left blank.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let year: number | null = null
|
||||||
|
if (packedForYear.trim()) {
|
||||||
|
const y = Number(packedForYear)
|
||||||
|
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
|
||||||
|
setError('Packed-for year should be a four-digit year.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
year = y
|
||||||
|
}
|
||||||
|
let costCents: number | null = null
|
||||||
|
if (cost.trim()) {
|
||||||
|
const c = Number(cost)
|
||||||
|
if (!Number.isFinite(c) || c < 0) {
|
||||||
|
setError('Cost must be an amount, or left blank.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
costCents = Math.round(c * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lot = {
|
||||||
|
vendor: vendor.trim(),
|
||||||
|
sourceUrl: '',
|
||||||
|
sku: sku.trim(),
|
||||||
|
lotCode: lotCode.trim(),
|
||||||
|
purchasedAt: null,
|
||||||
|
packedForYear: year,
|
||||||
|
quantity: qty,
|
||||||
|
unit: lotUnit,
|
||||||
|
costCents,
|
||||||
|
germinationPct: null,
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPlant: PlantInput | undefined
|
||||||
|
let plantId: number | undefined
|
||||||
|
if (selection === 'new') {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Name the new variety, or pick an existing plant above.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
|
||||||
|
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
|
||||||
|
setError(`Spacing must be at least 1 ${unitLabel}.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let daysToMaturity: number | null = null
|
||||||
|
if (days.trim()) {
|
||||||
|
const d = Number(days)
|
||||||
|
if (!Number.isInteger(d) || d < 1) {
|
||||||
|
setError('Days to maturity must be a whole number of days, or left blank.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
daysToMaturity = d
|
||||||
|
}
|
||||||
|
newPlant = {
|
||||||
|
...newPlantDefaults(proposal),
|
||||||
|
name: name.trim(),
|
||||||
|
category,
|
||||||
|
spacingCm,
|
||||||
|
daysToMaturity,
|
||||||
|
vendor: vendor.trim(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
plantId = selection
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await create.mutateAsync({ plantId, newPlant, lot })
|
||||||
|
toast.info(
|
||||||
|
res.plantIsNew
|
||||||
|
? `Added ${res.plant.name} and its seed lot.`
|
||||||
|
: `Recorded a seed lot for ${res.plant.name}.`,
|
||||||
|
)
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err, 'Could not save the packet.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Scan a seed packet" onClose={onClose} busy={busy}>
|
||||||
|
{!proposal ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Take a photo of the front of a seed packet and pansy reads the details off it — you review and
|
||||||
|
confirm before anything is saved.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* A hidden input is triggered by the buttons below. `capture` hints a
|
||||||
|
phone to open the camera; on desktop it's ignored and both buttons
|
||||||
|
open a file chooser. */}
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={onFile}
|
||||||
|
className="hidden"
|
||||||
|
aria-hidden
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{scan.isPending ? (
|
||||||
|
<p className="flex items-center gap-2 rounded-md bg-border/40 px-3 py-2 text-sm text-muted">
|
||||||
|
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
|
||||||
|
Reading the packet… this can take a few seconds.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Button type="button" onClick={() => fileInput.current?.click()}>
|
||||||
|
Take or choose a photo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{/* Not disabled while scanning — this is the way out of a slow scan.
|
||||||
|
Aborting a settled/absent request is a harmless no-op. */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
scanAbort.current?.abort()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={onConfirm} className="flex flex-col gap-4">
|
||||||
|
<ReadFields proposal={proposal} unit={unit} />
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<legend className="text-sm font-medium text-fg">This packet is…</legend>
|
||||||
|
{proposal.candidates.map((c) => (
|
||||||
|
<label
|
||||||
|
key={c.plant.id}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="packet-selection"
|
||||||
|
checked={selection === c.plant.id}
|
||||||
|
onChange={() => setSelection(c.plant.id)}
|
||||||
|
/>
|
||||||
|
<PlantIcon color={c.plant.color} icon={c.plant.icon} className="h-6 w-6 rounded text-sm" />
|
||||||
|
<span className="flex-1 font-medium text-fg">{c.plant.name}</span>
|
||||||
|
<span className="text-xs text-muted">{c.reason}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="packet-selection"
|
||||||
|
checked={selection === 'new'}
|
||||||
|
onChange={() => setSelection('new')}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 font-medium text-fg">
|
||||||
|
{proposal.candidates.length > 0 ? 'None of these — a new variety' : 'Add as a new variety'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{selection === 'new' && (
|
||||||
|
<div className="flex flex-col gap-3 rounded-md border border-border p-3">
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
hint="You can set an icon and color later from the plant card."
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Select
|
||||||
|
label="Category"
|
||||||
|
name="category"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value as PlantCategory)}
|
||||||
|
options={categoryOptions}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={`Spacing (${unitLabel})`}
|
||||||
|
name="spacing"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="any"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
value={spacing}
|
||||||
|
onChange={(e) => setSpacing(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
label="Days to maturity (optional)"
|
||||||
|
name="days"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
step="1"
|
||||||
|
min="1"
|
||||||
|
value={days}
|
||||||
|
onChange={(e) => setDays(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The seed lot — what you bought — recorded against whichever plant. */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm font-medium text-fg">Seed lot</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TextField
|
||||||
|
label="Quantity"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Unit"
|
||||||
|
name="unit"
|
||||||
|
value={lotUnit}
|
||||||
|
onChange={(e) => setLotUnit(e.target.value as LotUnit)}
|
||||||
|
options={unitOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
||||||
|
<TextField
|
||||||
|
label="Packed for"
|
||||||
|
name="packedForYear"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="2026"
|
||||||
|
value={packedForYear}
|
||||||
|
onChange={(e) => setPackedForYear(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
|
||||||
|
<TextField
|
||||||
|
label="Cost"
|
||||||
|
name="cost"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="4.99"
|
||||||
|
value={cost}
|
||||||
|
onChange={(e) => setCost(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
|
||||||
|
<div className="mt-1 flex justify-between gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
// Clear the review-phase error too, or it would show on the
|
||||||
|
// capture screen we're returning to.
|
||||||
|
setError(null)
|
||||||
|
setProposal(null)
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Rescan
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={busy}>
|
||||||
|
{create.isPending ? 'Saving…' : selection === 'new' ? 'Create plant + lot' : 'Add lot'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A compact read-only summary of what the model pulled off the packet, so the
|
||||||
|
* user can see the extraction at a glance while they confirm. Only fields that
|
||||||
|
* came back are shown. */
|
||||||
|
function ReadFields({ proposal, unit }: { proposal: PacketProposal; unit: UnitPref }) {
|
||||||
|
const p = proposal.packet
|
||||||
|
const rows: [string, string][] = []
|
||||||
|
if (p.species) rows.push(['Species', p.species])
|
||||||
|
if (p.variety) rows.push(['Variety', p.variety])
|
||||||
|
if (p.spacingCm != null) rows.push(['Spacing', `${spacingFromCm(p.spacingCm, unit)} ${spacingUnitLabel(unit)}`])
|
||||||
|
if (p.daysToMaturity != null) rows.push(['Days to maturity', String(p.daysToMaturity)])
|
||||||
|
if (p.seedCount != null) rows.push(['Seed count', String(p.seedCount)])
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return (
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md bg-border/30 px-3 py-2 text-sm">
|
||||||
|
{rows.map(([k, v]) => (
|
||||||
|
<div key={k} className="contents">
|
||||||
|
<dt className="text-muted">{k}</dt>
|
||||||
|
<dd className="text-fg">{v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
import { Component, Suspense, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
import { errorMessage } from '@/lib/api'
|
import { errorMessage } from '@/lib/api'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@@ -14,8 +14,31 @@ import {
|
|||||||
type AgentTurn,
|
type AgentTurn,
|
||||||
} from '@/lib/agent'
|
} from '@/lib/agent'
|
||||||
import { useUndo } from '@/lib/history'
|
import { useUndo } from '@/lib/history'
|
||||||
|
import { lazyPage } from '@/lib/lazyPage'
|
||||||
import { UndoButton } from './UndoButton'
|
import { UndoButton } from './UndoButton'
|
||||||
|
|
||||||
|
// Lazy so the markdown renderer + its ecosystem (~150 KB) loads only when an
|
||||||
|
// assistant message actually renders, not for everyone who opens the editor.
|
||||||
|
// lazyPage adds the stale-chunk recovery a plain lazy() lacks — a post-deploy
|
||||||
|
// chunk 404 would otherwise permanently break the assistant.
|
||||||
|
const MarkdownMessage = lazyPage(() => import('./MarkdownMessage'), 'MarkdownMessage')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Falls back to the raw message text if the markdown chunk can't load (a
|
||||||
|
* non-recoverable 404) or the renderer throws — a garbled reply should degrade to
|
||||||
|
* readable text, never take the whole editor down. Suspense handles the loading
|
||||||
|
* phase; this handles the failure one.
|
||||||
|
*/
|
||||||
|
class MarkdownBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
|
||||||
|
state = { failed: false }
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { failed: true }
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
return this.state.failed ? this.props.fallback : this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Talk to the garden assistant, in the editor beside the canvas.
|
* Talk to the garden assistant, in the editor beside the canvas.
|
||||||
*
|
*
|
||||||
@@ -106,8 +129,8 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
|||||||
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
|
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={clear.isPending}
|
disabled={clear.isPending || !!pending}
|
||||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Start over
|
Start over
|
||||||
</button>
|
</button>
|
||||||
@@ -120,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. */}
|
||||||
@@ -227,11 +250,27 @@ function Bubble({
|
|||||||
<div className={cn('flex flex-col', mine ? 'items-end' : 'items-start')}>
|
<div className={cn('flex flex-col', mine ? 'items-end' : 'items-start')}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-[90%] whitespace-pre-wrap rounded-lg px-2.5 py-2 text-sm',
|
'rounded-lg px-2.5 py-2 text-sm',
|
||||||
mine ? 'bg-accent/15 text-fg' : 'border border-border text-fg',
|
// The user's own text is literal (their `*` shouldn't become a bullet)
|
||||||
|
// and hugs the right; the assistant's Markdown is rendered and gets the
|
||||||
|
// full width so a table has room.
|
||||||
|
mine
|
||||||
|
? 'max-w-[90%] whitespace-pre-wrap bg-accent/15 text-fg'
|
||||||
|
: 'w-full border border-border text-fg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{body}
|
{mine ? (
|
||||||
|
body
|
||||||
|
) : (
|
||||||
|
// Show the raw text until the renderer chunk arrives (Suspense), and fall
|
||||||
|
// back to it if the chunk can't load or the renderer throws (boundary) —
|
||||||
|
// either way the message is readable, never blank and never a crash.
|
||||||
|
<MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{body}</span>}>
|
||||||
|
<Suspense fallback={<span className="whitespace-pre-wrap">{body}</span>}>
|
||||||
|
<MarkdownMessage>{body}</MarkdownMessage>
|
||||||
|
</Suspense>
|
||||||
|
</MarkdownBoundary>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,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('')
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { MarkdownMessage } from './MarkdownMessage'
|
||||||
|
|
||||||
|
// renderToStaticMarkup needs no DOM, so this runs in the default (node) env and
|
||||||
|
// proves the assistant's Markdown — GFM tables in particular — actually renders.
|
||||||
|
function render(md: string): string {
|
||||||
|
return renderToStaticMarkup(<MarkdownMessage>{md}</MarkdownMessage>)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MarkdownMessage', () => {
|
||||||
|
it('renders a GFM table with styled cells', () => {
|
||||||
|
const html = render('| Bed | Plant |\n| --- | --- |\n| North | Garlic |')
|
||||||
|
expect(html).toContain('<table')
|
||||||
|
expect(html).toContain('border-collapse')
|
||||||
|
expect(html).toContain('<th')
|
||||||
|
expect(html).toContain('<td')
|
||||||
|
expect(html).toContain('Garlic')
|
||||||
|
// Wide tables scroll inside their own box rather than blowing out the bubble.
|
||||||
|
expect(html).toContain('overflow-x-auto')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders inline formatting and lists', () => {
|
||||||
|
const html = render('**bold** and *italic*\n\n- one\n- two')
|
||||||
|
expect(html).toContain('<strong')
|
||||||
|
expect(html).toContain('<em')
|
||||||
|
expect(html).toContain('<ul')
|
||||||
|
expect(html).toContain('<li')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit raw HTML from the model (no rehype-raw)', () => {
|
||||||
|
const html = render('Hi <script>alert(1)</script> <b>x</b>')
|
||||||
|
expect(html).not.toContain('<script>')
|
||||||
|
expect(html).not.toContain('<b>x</b>') // the literal tag is escaped, not rendered
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens links safely in a new tab', () => {
|
||||||
|
const html = render('[seeds](https://example.com)')
|
||||||
|
expect(html).toContain('href="https://example.com"')
|
||||||
|
expect(html).toContain('rel="noopener noreferrer"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render images (no auto-loading exfiltration beacon)', () => {
|
||||||
|
// A prompt-injected reply could embed ``; the browser
|
||||||
|
// would auto-fetch it, leaking that the message was viewed (and anything smuggled
|
||||||
|
// into the URL). We forbid <img> entirely, so the beacon never fires.
|
||||||
|
const html = render('before  after')
|
||||||
|
expect(html).not.toContain('<img')
|
||||||
|
expect(html).not.toContain('evil.example')
|
||||||
|
// Surrounding prose still renders.
|
||||||
|
expect(html).toContain('before')
|
||||||
|
expect(html).toContain('after')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honours GFM column alignment', () => {
|
||||||
|
const html = render('| L | C | R |\n| :-- | :--: | --: |\n| a | b | c |')
|
||||||
|
expect(html).toContain('text-align:center')
|
||||||
|
expect(html).toContain('text-align:right')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { memo, type ReactNode } from 'react'
|
||||||
|
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
||||||
|
// Hoisted so they're not re-created every render (which would defeat both React's
|
||||||
|
// and ReactMarkdown's memoization).
|
||||||
|
const remarkPlugins = [remarkGfm]
|
||||||
|
const CODE_BLOCK = /language-/
|
||||||
|
|
||||||
|
// The assistant's replies are never trusted markup: their content can be steered
|
||||||
|
// by anything the agent read (a shared garden's notes, a seed vendor page). So we
|
||||||
|
// render Markdown but NOT raw HTML (no rehype-raw), and — belt to that — forbid
|
||||||
|
// <img>, whose auto-loading `src` is a prompt-injection exfiltration beacon
|
||||||
|
// (``); the assistant has no reason to emit images.
|
||||||
|
const disallowedElements = ['img']
|
||||||
|
|
||||||
|
// Tailwind's reset strips default list/table styling, so every element the
|
||||||
|
// assistant actually uses is restyled here, scaled for a chat bubble. Wide
|
||||||
|
// content (tables, code) scrolls in its own box so the bubble never blows out.
|
||||||
|
const bigHeading = ({ children }: { children?: ReactNode }) => (
|
||||||
|
<h4 className="mb-1 mt-2 text-sm font-semibold first:mt-0">{children}</h4>
|
||||||
|
)
|
||||||
|
const smallHeading = ({ children }: { children?: ReactNode }) => (
|
||||||
|
<h5 className="mb-1 mt-1.5 text-xs font-semibold uppercase tracking-wide text-muted first:mt-0">
|
||||||
|
{children}
|
||||||
|
</h5>
|
||||||
|
)
|
||||||
|
|
||||||
|
const components: Components = {
|
||||||
|
p: ({ children }) => <p className="my-1.5 first:mt-0 last:mb-0">{children}</p>,
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent-strong underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
ul: ({ children }) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
|
||||||
|
ol: ({ children, start }) => (
|
||||||
|
<ol start={start} className="my-1.5 list-decimal pl-5">
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) => <li className="my-0.5">{children}</li>,
|
||||||
|
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||||
|
em: ({ children }) => <em className="italic">{children}</em>,
|
||||||
|
h1: bigHeading,
|
||||||
|
h2: bigHeading,
|
||||||
|
h3: bigHeading,
|
||||||
|
h4: smallHeading,
|
||||||
|
h5: smallHeading,
|
||||||
|
h6: smallHeading,
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="my-1.5 border-l-2 border-border pl-2 text-muted">{children}</blockquote>
|
||||||
|
),
|
||||||
|
hr: () => <hr className="my-2 border-border" />,
|
||||||
|
code: ({ className, children }) => {
|
||||||
|
// A fenced block is wrapped by <pre> (styled below) and carries either a
|
||||||
|
// language- class or a trailing newline; inline code is a single-line bare
|
||||||
|
// <code> and gets the pill treatment. (The newline check catches fences with
|
||||||
|
// no info-string, which have no language- class.)
|
||||||
|
const isBlock = CODE_BLOCK.test(className ?? '') || String(children).includes('\n')
|
||||||
|
if (isBlock) return <code className={className}>{children}</code>
|
||||||
|
return (
|
||||||
|
<code className="rounded bg-border/60 px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pre: ({ children }) => (
|
||||||
|
<pre className="my-1.5 overflow-x-auto rounded-md bg-border/40 p-2 font-mono text-xs">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
table: ({ children }) => (
|
||||||
|
<div className="my-1.5 overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-xs">{children}</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
// Pass `style` through: GFM column alignment (`:---:` / `---:`) arrives as
|
||||||
|
// style.textAlign, and dropping it would silently discard it.
|
||||||
|
th: ({ children, style }) => (
|
||||||
|
<th style={style} className="border border-border px-2 py-1 font-semibold">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
td: ({ children, style }) => (
|
||||||
|
<td style={style} className="border border-border px-2 py-1 align-top">
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render one assistant message body as Markdown (GFM). Memoized so typing in the
|
||||||
|
* composer doesn't re-parse every message in the thread. */
|
||||||
|
export const MarkdownMessage = memo(function MarkdownMessage({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<div className="leading-relaxed">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={remarkPlugins}
|
||||||
|
disallowedElements={disallowedElements}
|
||||||
|
components={components}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -3,7 +3,17 @@
|
|||||||
// one place instead of drifting between files.
|
// one place instead of drifting between files.
|
||||||
|
|
||||||
export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles
|
export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles
|
||||||
export const HANDLE_PX = 12 // on-screen size of a drag/resize handle
|
// Whether the primary pointer is a fingertip rather than a mouse — the one signal
|
||||||
|
// the touch affordances key off (bigger handles here, the on-screen nudge pad in
|
||||||
|
// the editor), so they can't disagree about what "touch" means. Read once at
|
||||||
|
// load; a device doesn't switch its primary pointer mid-session, and the optional
|
||||||
|
// chain keeps it false (mouse defaults) under test / SSR where matchMedia is absent.
|
||||||
|
export const isCoarsePointer =
|
||||||
|
typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: coarse)').matches
|
||||||
|
// On-screen size of a drag/resize handle. Bigger on touch so a fingertip can
|
||||||
|
// actually grab a resize corner or the rotate knob — 12px is fine for a mouse but
|
||||||
|
// frustrating for a thumb (#104).
|
||||||
|
export const HANDLE_PX = isCoarsePointer ? 22 : 12
|
||||||
export const MIN_RADIUS_CM = 1 // smallest plop radius
|
export const MIN_RADIUS_CM = 1 // smallest plop radius
|
||||||
export const DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode
|
export const DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode
|
||||||
|
|
||||||
|
|||||||
+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,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
+17
-8
@@ -11,18 +11,27 @@ import { API_BASE, api } from './api'
|
|||||||
import { gardenFullKey } from './objects'
|
import { gardenFullKey } from './objects'
|
||||||
import { historyKey } from './history'
|
import { historyKey } from './history'
|
||||||
|
|
||||||
const capabilitiesSchema = z.object({ agent: z.boolean() })
|
// What this instance can actually do, so the UI offers only what works: `agent`
|
||||||
|
// (the assistant is live now) and `vision` (a seed-packet scan will work). Both
|
||||||
|
// default to false so a partial/older response just hides the feature rather
|
||||||
|
// than failing the whole parse.
|
||||||
|
const capabilitiesSchema = z.object({
|
||||||
|
agent: z.boolean().default(false),
|
||||||
|
vision: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
export type Capabilities = z.infer<typeof capabilitiesSchema>
|
||||||
|
|
||||||
export const capabilitiesKey = ['capabilities'] as const
|
export const capabilitiesKey = ['capabilities'] as const
|
||||||
|
|
||||||
/** Whether the assistant is live RIGHT NOW. Without it the panel isn't rendered
|
/** What this instance can do right now: whether the assistant is live and whether
|
||||||
* at all — a dead button is worse than no button.
|
* seed-packet scanning will work. Without a capability the matching feature isn't
|
||||||
|
* offered at all — a dead button is worse than no button.
|
||||||
*
|
*
|
||||||
* Not `staleTime: Infinity` any more: an admin can turn the assistant on or off
|
* Not `staleTime: Infinity` any more: an admin can turn the assistant or a vision
|
||||||
* in Settings (#79), so this must be able to change under a running page. The
|
* model on or off in Settings (#79), so this must be able to change under a
|
||||||
* settings save invalidates this key directly; the finite staleTime just means
|
* running page. The settings save invalidates this key directly; the finite
|
||||||
* another admin's change is picked up on the next focus/remount rather than
|
* staleTime just means another admin's change is picked up on the next
|
||||||
* never. */
|
* focus/remount rather than never. */
|
||||||
export function useCapabilities() {
|
export function useCapabilities() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: capabilitiesKey,
|
queryKey: capabilitiesKey,
|
||||||
|
|||||||
+13
-6
@@ -42,7 +42,8 @@ export type Params = Record<string, ParamValue>
|
|||||||
|
|
||||||
export interface RequestOptions {
|
export interface RequestOptions {
|
||||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||||
/** JSON request body; serialized and sent with a JSON content-type. */
|
/** Request body. A `FormData` goes out as multipart (a file upload — the
|
||||||
|
* seed-packet scan); anything else is serialized as JSON. */
|
||||||
body?: unknown
|
body?: unknown
|
||||||
/** Query-string parameters; undefined/null/'' entries are omitted. */
|
/** Query-string parameters; undefined/null/'' entries are omitted. */
|
||||||
params?: Params
|
params?: Params
|
||||||
@@ -90,9 +91,13 @@ function messageFrom(body: unknown, status: number): string {
|
|||||||
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||||
const { method = 'GET', body, params, signal } = opts
|
const { method = 'GET', body, params, signal } = opts
|
||||||
|
|
||||||
// Serialize before the try so a JSON.stringify failure (e.g. a circular value)
|
// FormData must go out as multipart with a browser-generated boundary, so it's
|
||||||
// surfaces as itself, not as a misleading "cannot reach the server" error.
|
// sent as-is with NO content-type header (the browser sets it, boundary
|
||||||
const requestBody = body !== undefined ? JSON.stringify(body) : undefined
|
// included). Everything else is JSON — serialized before the try so a
|
||||||
|
// JSON.stringify failure (e.g. a circular value) surfaces as itself, not as a
|
||||||
|
// misleading "cannot reach the server" error.
|
||||||
|
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
|
||||||
|
const requestBody = body === undefined ? undefined : isForm ? body : JSON.stringify(body)
|
||||||
|
|
||||||
let res: Response
|
let res: Response
|
||||||
try {
|
try {
|
||||||
@@ -102,9 +107,9 @@ export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Prom
|
|||||||
credentials: 'same-origin', // send the HttpOnly session cookie
|
credentials: 'same-origin', // send the HttpOnly session cookie
|
||||||
headers: {
|
headers: {
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
...(body !== undefined && !isForm ? { 'content-type': 'application/json' } : {}),
|
||||||
},
|
},
|
||||||
body: requestBody,
|
body: requestBody as BodyInit | undefined,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error)?.name === 'AbortError') throw err
|
if ((err as Error)?.name === 'AbortError') throw err
|
||||||
@@ -143,6 +148,8 @@ export const api = {
|
|||||||
apiFetch<T>(path, { ...opts, method: 'GET' }),
|
apiFetch<T>(path, { ...opts, method: 'GET' }),
|
||||||
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||||
apiFetch<T>(path, { ...opts, method: 'POST', body }),
|
apiFetch<T>(path, { ...opts, method: 'POST', body }),
|
||||||
|
postForm: <T>(path: string, form: FormData, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||||
|
apiFetch<T>(path, { ...opts, method: 'POST', body: form }),
|
||||||
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||||
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
|
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
|
||||||
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { lazy, type ComponentType } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily load a component by its named export, with recovery for the stale-chunk
|
||||||
|
* problem. A push to main redeploys, so a still-open app references chunk hashes
|
||||||
|
* the server has just replaced; that import 404s and React.lazy MEMOIZES the
|
||||||
|
* rejection, so a "Try again" can never recover — the user is stuck until a manual
|
||||||
|
* hard reload. On the first such failure we reload once (fetching the fresh index
|
||||||
|
* + hashes); a session flag stops a reload loop, and a success clears it so a
|
||||||
|
* later genuine failure can reload again.
|
||||||
|
*
|
||||||
|
* Shared by the route splits (router.tsx) and any feature-level lazy load (the
|
||||||
|
* assistant's Markdown renderer), so they all get the same recovery.
|
||||||
|
*/
|
||||||
|
export function lazyPage<M, K extends keyof M>(load: () => Promise<M>, name: K) {
|
||||||
|
// Preserve the component's own prop type so callers keep type-checked props
|
||||||
|
// (e.g. MarkdownMessage's `children: string`), rather than erasing to `{}`.
|
||||||
|
type C = M[K] extends ComponentType<infer P> ? ComponentType<P> : never
|
||||||
|
return lazy<C>(async () => {
|
||||||
|
try {
|
||||||
|
const mod = await load()
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem('pansy:chunk-reload')
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable — fine */
|
||||||
|
}
|
||||||
|
return { default: mod[name] as C }
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
if (!sessionStorage.getItem('pansy:chunk-reload')) {
|
||||||
|
sessionStorage.setItem('pansy:chunk-reload', '1')
|
||||||
|
window.location.reload()
|
||||||
|
return await new Promise<{ default: C }>(() => {}) // hold for the reload
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable — fall through to surface the error */
|
||||||
|
}
|
||||||
|
throw err // already reloaded once (or can't); let the error boundary show it
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ export function filterPlants(plants: Plant[], query: string, category: CategoryF
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const plantsKey = ['plants'] as const
|
export const plantsKey = ['plants'] as const
|
||||||
|
|
||||||
export const plantsQueryOptions = queryOptions({
|
export const plantsQueryOptions = queryOptions({
|
||||||
queryKey: plantsKey,
|
queryKey: plantsKey,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const seedLotSchema = z.object({
|
|||||||
})
|
})
|
||||||
export type SeedLot = z.infer<typeof seedLotSchema>
|
export type SeedLot = z.infer<typeof seedLotSchema>
|
||||||
|
|
||||||
const seedLotsKey = ['seed-lots'] as const
|
export const seedLotsKey = ['seed-lots'] as const
|
||||||
|
|
||||||
export function useSeedLots() {
|
export function useSeedLots() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
lotDefaults,
|
||||||
|
newPlantDefaults,
|
||||||
|
packetProposalSchema,
|
||||||
|
seedPacketSchema,
|
||||||
|
PACKET_PLANT_COLOR,
|
||||||
|
PACKET_PLANT_ICON,
|
||||||
|
} from './seedPacket'
|
||||||
|
|
||||||
|
// A full proposal as the backend sends it, for the schema + prefill tests.
|
||||||
|
const proposal = {
|
||||||
|
packet: {
|
||||||
|
species: 'garlic',
|
||||||
|
variety: 'Music',
|
||||||
|
category: 'vegetable',
|
||||||
|
vendor: "Johnny's",
|
||||||
|
sku: 'G-123',
|
||||||
|
lotCode: 'L9',
|
||||||
|
packedForYear: 2026,
|
||||||
|
daysToMaturity: 90,
|
||||||
|
spacingCm: 15,
|
||||||
|
seedCount: 12,
|
||||||
|
},
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
plant: {
|
||||||
|
id: 7,
|
||||||
|
name: 'Music',
|
||||||
|
category: 'vegetable',
|
||||||
|
spacingCm: 15,
|
||||||
|
color: '#fff',
|
||||||
|
icon: '🧄',
|
||||||
|
notes: '',
|
||||||
|
version: 1,
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
reason: 'exact name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
suggestedName: 'Music',
|
||||||
|
suggestedCategory: 'vegetable',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('seedPacketSchema', () => {
|
||||||
|
it('fills defaults for a sparse packet (only what was printed)', () => {
|
||||||
|
// A packet where the model only read a species — everything else absent.
|
||||||
|
const p = seedPacketSchema.parse({ species: 'basil' })
|
||||||
|
expect(p.species).toBe('basil')
|
||||||
|
expect(p.variety).toBe('')
|
||||||
|
expect(p.spacingCm).toBeNull()
|
||||||
|
expect(p.seedCount).toBeNull()
|
||||||
|
expect(p.packedForYear).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('packetProposalSchema', () => {
|
||||||
|
it('parses a full proposal including candidates', () => {
|
||||||
|
const p = packetProposalSchema.parse(proposal)
|
||||||
|
expect(p.candidates).toHaveLength(1)
|
||||||
|
expect(p.candidates[0].plant.id).toBe(7)
|
||||||
|
expect(p.candidates[0].reason).toBe('exact name')
|
||||||
|
expect(p.suggestedName).toBe('Music')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults candidates to empty when absent', () => {
|
||||||
|
const p = packetProposalSchema.parse({ packet: { species: 'kale' } })
|
||||||
|
expect(p.candidates).toEqual([])
|
||||||
|
expect(p.suggestedName).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('newPlantDefaults', () => {
|
||||||
|
it('prefills name/category/spacing/days/vendor from the proposal', () => {
|
||||||
|
const np = newPlantDefaults(packetProposalSchema.parse(proposal))
|
||||||
|
expect(np.name).toBe('Music')
|
||||||
|
expect(np.category).toBe('vegetable')
|
||||||
|
expect(np.spacingCm).toBe(15)
|
||||||
|
expect(np.daysToMaturity).toBe(90)
|
||||||
|
expect(np.vendor).toBe("Johnny's")
|
||||||
|
// No icon/color on a packet — placeholders the user can change later.
|
||||||
|
expect(np.icon).toBe(PACKET_PLANT_ICON)
|
||||||
|
expect(np.color).toBe(PACKET_PLANT_COLOR)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a safe category and default spacing when the packet lacks them', () => {
|
||||||
|
const np = newPlantDefaults(
|
||||||
|
packetProposalSchema.parse({
|
||||||
|
packet: { species: 'mystery' },
|
||||||
|
suggestedName: 'mystery',
|
||||||
|
suggestedCategory: 'not-a-real-category',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// An unknown category must not leak through — CreatePlant would reject it.
|
||||||
|
expect(np.category).toBe('vegetable')
|
||||||
|
// No printed spacing → the same default the manual form uses.
|
||||||
|
expect(np.spacingCm).toBe(30)
|
||||||
|
expect(np.daysToMaturity).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lotDefaults', () => {
|
||||||
|
it('reads a seed count as "<n> seeds"', () => {
|
||||||
|
const lot = lotDefaults(seedPacketSchema.parse(proposal.packet))
|
||||||
|
expect(lot.quantity).toBe(12)
|
||||||
|
expect(lot.unit).toBe('seeds')
|
||||||
|
expect(lot.vendor).toBe("Johnny's")
|
||||||
|
expect(lot.sku).toBe('G-123')
|
||||||
|
expect(lot.lotCode).toBe('L9')
|
||||||
|
expect(lot.packedForYear).toBe(2026)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to one packet when no seed count is printed', () => {
|
||||||
|
const lot = lotDefaults(seedPacketSchema.parse({ species: 'basil' }))
|
||||||
|
expect(lot.quantity).toBe(1)
|
||||||
|
expect(lot.unit).toBe('packets')
|
||||||
|
expect(lot.packedForYear).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Seed-packet capture client (#81/#102). Two steps, deliberately separate — the
|
||||||
|
// same shape the backend enforces:
|
||||||
|
// POST /seed-lots/scan a packet photo → a PacketProposal (reads only)
|
||||||
|
// POST /seed-lots/from-packet a confirmed proposal → a plant + a seed lot
|
||||||
|
// The scan never writes; a misread can't add anything to the catalog on its own.
|
||||||
|
// Creation happens only from an explicit confirm, with exactly one of an existing
|
||||||
|
// plant (plantId) or a new variety (newPlant).
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { api } from './api'
|
||||||
|
import { PLANT_CATEGORIES, plantSchema, plantsKey, type PlantCategory, type PlantInput } from './plants'
|
||||||
|
import { seedLotSchema, seedLotsKey, type LotUnit, type SeedLotInput } from './seedLots'
|
||||||
|
|
||||||
|
// SeedPacket mirrors internal/vision.SeedPacket: the fields read off a packet.
|
||||||
|
// Every field can be empty or null because the model fills only what's actually
|
||||||
|
// printed — so each has a default and nothing here is required.
|
||||||
|
export const seedPacketSchema = z.object({
|
||||||
|
species: z.string().default(''),
|
||||||
|
variety: z.string().default(''),
|
||||||
|
category: z.string().default(''),
|
||||||
|
vendor: z.string().default(''),
|
||||||
|
sku: z.string().default(''),
|
||||||
|
lotCode: z.string().default(''),
|
||||||
|
packedForYear: z.number().nullable().default(null),
|
||||||
|
daysToMaturity: z.number().nullable().default(null),
|
||||||
|
spacingCm: z.number().nullable().default(null),
|
||||||
|
seedCount: z.number().nullable().default(null),
|
||||||
|
})
|
||||||
|
export type SeedPacket = z.infer<typeof seedPacketSchema>
|
||||||
|
|
||||||
|
// A candidate existing plant the packet might already be, with why it matched so
|
||||||
|
// the UI can show the reason next to it.
|
||||||
|
export const packetMatchSchema = z.object({ plant: plantSchema, reason: z.string() })
|
||||||
|
export type PacketMatch = z.infer<typeof packetMatchSchema>
|
||||||
|
|
||||||
|
// What a scan returns: the read fields, the candidate existing plants (best
|
||||||
|
// first; empty means "probably new"), and prefill hints for a new variety.
|
||||||
|
export const packetProposalSchema = z.object({
|
||||||
|
packet: seedPacketSchema,
|
||||||
|
candidates: z.array(packetMatchSchema).default([]),
|
||||||
|
suggestedName: z.string().default(''),
|
||||||
|
suggestedCategory: z.string().default(''),
|
||||||
|
})
|
||||||
|
export type PacketProposal = z.infer<typeof packetProposalSchema>
|
||||||
|
|
||||||
|
// What a confirm produced: the plant (new or matched) and the created lot.
|
||||||
|
export const packetResultSchema = z.object({
|
||||||
|
plant: plantSchema,
|
||||||
|
lot: seedLotSchema,
|
||||||
|
plantIsNew: z.boolean(),
|
||||||
|
})
|
||||||
|
export type PacketResult = z.infer<typeof packetResultSchema>
|
||||||
|
|
||||||
|
// The lot half of a confirm carries no plantId — the plant comes from the
|
||||||
|
// plantId/newPlant choice, and the server attributes the lot to it.
|
||||||
|
export type SeedLotFields = Omit<SeedLotInput, 'plantId'>
|
||||||
|
|
||||||
|
// A confirmed proposal: exactly one of plantId (attach to an existing plant) or
|
||||||
|
// newPlant (create a variety), plus the lot to record.
|
||||||
|
export interface FromPacketBody {
|
||||||
|
plantId?: number
|
||||||
|
newPlant?: PlantInput
|
||||||
|
lot: SeedLotFields
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan a packet photo into a proposal. Multipart upload; the vision call can
|
||||||
|
* take several seconds (the server extends its deadline to 120s), so a `signal`
|
||||||
|
* can be threaded through to abort a slow/hung scan — the caller wires it to a
|
||||||
|
* Cancel button so the dialog is never a trap. Not cached — every photo is a
|
||||||
|
* fresh one-shot. */
|
||||||
|
export function useScanPacket() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ file, signal }: { file: File; signal?: AbortSignal }): Promise<PacketProposal> => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('image', file)
|
||||||
|
return packetProposalSchema.parse(await api.postForm('/seed-lots/scan', form, { signal }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm a proposal into a plant + lot. Invalidates both catalogs the new rows
|
||||||
|
* show up in. */
|
||||||
|
export function useCreateFromPacket() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: FromPacketBody): Promise<PacketResult> =>
|
||||||
|
packetResultSchema.parse(await api.post('/seed-lots/from-packet', body)),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: plantsKey })
|
||||||
|
void qc.invalidateQueries({ queryKey: seedLotsKey })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A packet has no icon or color, so a new variety created from one gets these
|
||||||
|
// placeholders; the user can set a real icon/color later from the plant card.
|
||||||
|
export const PACKET_PLANT_COLOR = '#4a7c3f'
|
||||||
|
export const PACKET_PLANT_ICON = '🌱'
|
||||||
|
// A packet without a printed spacing falls back to this (cm) — the same default
|
||||||
|
// the manual new-plant form uses.
|
||||||
|
const DEFAULT_SPACING_CM = 30
|
||||||
|
|
||||||
|
function isPlantCategory(c: string): c is PlantCategory {
|
||||||
|
return (PLANT_CATEGORIES as readonly string[]).includes(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill a new-variety form from a proposal. Name and category come from the
|
||||||
|
* proposal's suggestions (already validated server-side against the known
|
||||||
|
* categories, but re-checked here); spacing/days/vendor come off the packet.
|
||||||
|
* Color and icon aren't on a packet, so they take the placeholders above.
|
||||||
|
*/
|
||||||
|
export function newPlantDefaults(p: PacketProposal): PlantInput {
|
||||||
|
return {
|
||||||
|
name: p.suggestedName,
|
||||||
|
category: isPlantCategory(p.suggestedCategory) ? p.suggestedCategory : 'vegetable',
|
||||||
|
spacingCm: p.packet.spacingCm ?? DEFAULT_SPACING_CM,
|
||||||
|
color: PACKET_PLANT_COLOR,
|
||||||
|
icon: PACKET_PLANT_ICON,
|
||||||
|
daysToMaturity: p.packet.daysToMaturity,
|
||||||
|
sourceUrl: '',
|
||||||
|
vendor: p.packet.vendor,
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill the lot fields from the packet. A printed seed count reads naturally as
|
||||||
|
* "<n> seeds"; without one, default to a single packet — the thing you physically
|
||||||
|
* bought — which the user can correct.
|
||||||
|
*/
|
||||||
|
export function lotDefaults(p: SeedPacket): SeedLotFields {
|
||||||
|
const hasCount = p.seedCount != null && p.seedCount > 0
|
||||||
|
const unit: LotUnit = hasCount ? 'seeds' : 'packets'
|
||||||
|
return {
|
||||||
|
vendor: p.vendor,
|
||||||
|
sourceUrl: '',
|
||||||
|
sku: p.sku,
|
||||||
|
lotCode: p.lotCode,
|
||||||
|
purchasedAt: null,
|
||||||
|
packedForYear: p.packedForYear,
|
||||||
|
quantity: hasCount ? (p.seedCount as number) : 1,
|
||||||
|
unit,
|
||||||
|
costCents: null,
|
||||||
|
germinationPct: null,
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { 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'
|
||||||
@@ -13,14 +13,17 @@ import { PlantPicker } from '@/editor/PlantPicker'
|
|||||||
import { Palette } from '@/editor/Palette'
|
import { Palette } from '@/editor/Palette'
|
||||||
import { SeedTray } from '@/editor/SeedTray'
|
import { SeedTray } from '@/editor/SeedTray'
|
||||||
import { RecentPlants } from '@/editor/RecentPlants'
|
import { RecentPlants } from '@/editor/RecentPlants'
|
||||||
|
import { ScanPacketModal } from '@/components/plants/ScanPacketModal'
|
||||||
import { ClearBedModal } from '@/editor/ClearBedModal'
|
import { ClearBedModal } from '@/editor/ClearBedModal'
|
||||||
import { EditorHint } from '@/editor/EditorHint'
|
import { EditorHint } from '@/editor/EditorHint'
|
||||||
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
|
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
|
||||||
import { objectDisplayName } from '@/editor/kinds'
|
import { objectDisplayName } from '@/editor/kinds'
|
||||||
import { useEditorStore, type EditorMode } from '@/editor/store'
|
import { useEditorStore, type EditorMode } from '@/editor/store'
|
||||||
|
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,
|
||||||
@@ -83,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(
|
||||||
@@ -107,6 +112,10 @@ export function GardenEditorPage() {
|
|||||||
const [picker, setPicker] = useState<'place' | 'change' | null>(null)
|
const [picker, setPicker] = useState<'place' | 'change' | null>(null)
|
||||||
const [sharing, setSharing] = useState(false)
|
const [sharing, setSharing] = useState(false)
|
||||||
const [clearing, setClearing] = useState(false)
|
const [clearing, setClearing] = useState(false)
|
||||||
|
const [scanning, setScanning] = useState(false)
|
||||||
|
// Whether to offer packet scanning — a vision model is configured. Read once
|
||||||
|
// here so all three Plants-mode entry points gate identically.
|
||||||
|
const canScan = !!capabilities.data?.vision
|
||||||
const nudgeTimer = useRef<number | null>(null)
|
const nudgeTimer = useRef<number | null>(null)
|
||||||
const nudgeFire = useRef<(() => void) | null>(null)
|
const nudgeFire = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
@@ -283,13 +292,67 @@ export function GardenEditorPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Desktop keyboard nudging: arrows move the selected object/plop 1cm (Shift =
|
// Move the selected object/plop by (dx, dy) cm: apply live geometry instantly,
|
||||||
// 10cm); the PATCH is debounced ~400ms on key-idle so a held key doesn't spam.
|
// then commit ONE debounced PATCH so a burst of nudges (a held arrow key, or
|
||||||
// Plops nudge in their object's local frame and clamp to its (local) bounds.
|
// repeated taps of the touch pad) doesn't spam the server. Plops clamp to their
|
||||||
// Mounted once, reading live values from nudgeCtx so a data refetch can't
|
// object's local bounds. Reads live values from getState/nudgeCtx so it's
|
||||||
// re-subscribe and cancel a pending commit; the pending commit is flushed on
|
// correct whichever surface calls it; a commit only fires if its live value is
|
||||||
// unmount, and a fire only commits if its live value is still present (a drag
|
// still present (a drag that cleared it already committed its own PATCH).
|
||||||
// that cleared it already committed its own PATCH).
|
// Stable across renders (empty deps): both read live values through refs
|
||||||
|
// (nudgeCtx) / getState, never through closed-over props, so a mount-once
|
||||||
|
// consumer (the keydown effect) and a memo-friendly one (NudgePad) both get a
|
||||||
|
// function that stays current without a new identity each render.
|
||||||
|
const commitLater = useCallback((fire: () => void) => {
|
||||||
|
nudgeFire.current = fire
|
||||||
|
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
|
||||||
|
nudgeTimer.current = window.setTimeout(() => {
|
||||||
|
nudgeTimer.current = null
|
||||||
|
const fn = nudgeFire.current
|
||||||
|
nudgeFire.current = null
|
||||||
|
fn?.()
|
||||||
|
}, 400)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const nudgeSelected = useCallback((dx: number, dy: number) => {
|
||||||
|
const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } =
|
||||||
|
nudgeCtx.current
|
||||||
|
if (!canNudge) return
|
||||||
|
const s = useEditorStore.getState()
|
||||||
|
if (s.objectDragging) return // don't fight an active pointer drag
|
||||||
|
if (s.selectedId != null) {
|
||||||
|
const base = s.liveObject?.id === s.selectedId ? s.liveObject : objs.find((o) => o.id === s.selectedId)
|
||||||
|
if (!base) return
|
||||||
|
s.setLiveObject({ ...base, xCm: base.xCm + dx, yCm: base.yCm + dy })
|
||||||
|
commitLater(() => {
|
||||||
|
const live = useEditorStore.getState().liveObject
|
||||||
|
if (live?.id !== s.selectedId) return
|
||||||
|
uo.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
|
||||||
|
useEditorStore.getState().setLiveObject(null)
|
||||||
|
})
|
||||||
|
} else if (s.selectedPlantingId != null) {
|
||||||
|
const base =
|
||||||
|
s.livePlanting?.id === s.selectedPlantingId ? s.livePlanting : plops.find((p) => p.id === s.selectedPlantingId)
|
||||||
|
if (!base) return
|
||||||
|
const obj = objs.find((o) => o.id === base.objectId)
|
||||||
|
let nx = base.xCm + dx
|
||||||
|
let ny = base.yCm + dy
|
||||||
|
if (obj) {
|
||||||
|
nx = Math.max(-obj.widthCm / 2, Math.min(obj.widthCm / 2, nx))
|
||||||
|
ny = Math.max(-obj.heightCm / 2, Math.min(obj.heightCm / 2, ny))
|
||||||
|
}
|
||||||
|
s.setLivePlanting({ ...base, xCm: nx, yCm: ny })
|
||||||
|
commitLater(() => {
|
||||||
|
const live = useEditorStore.getState().livePlanting
|
||||||
|
if (live?.id !== s.selectedPlantingId) return
|
||||||
|
up.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
|
||||||
|
useEditorStore.getState().setLivePlanting(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [commitLater])
|
||||||
|
|
||||||
|
// Keyboard nudging (desktop): arrows move the selection 1cm, Shift = 10cm — the
|
||||||
|
// same nudgeSelected the touch pad uses. Mounted once; a pending commit is
|
||||||
|
// flushed on unmount so a nudge in flight isn't lost.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const DIRS: Record<string, [number, number]> = {
|
const DIRS: Record<string, [number, number]> = {
|
||||||
ArrowUp: [0, -1],
|
ArrowUp: [0, -1],
|
||||||
@@ -297,58 +360,16 @@ export function GardenEditorPage() {
|
|||||||
ArrowLeft: [-1, 0],
|
ArrowLeft: [-1, 0],
|
||||||
ArrowRight: [1, 0],
|
ArrowRight: [1, 0],
|
||||||
}
|
}
|
||||||
const commitLater = (fire: () => void) => {
|
|
||||||
nudgeFire.current = fire
|
|
||||||
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
|
|
||||||
nudgeTimer.current = window.setTimeout(() => {
|
|
||||||
nudgeTimer.current = null
|
|
||||||
const fn = nudgeFire.current
|
|
||||||
nudgeFire.current = null
|
|
||||||
fn?.()
|
|
||||||
}, 400)
|
|
||||||
}
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } =
|
|
||||||
nudgeCtx.current
|
|
||||||
if (!canNudge) return
|
|
||||||
const dir = DIRS[e.key]
|
const dir = DIRS[e.key]
|
||||||
if (!dir) return
|
if (!dir) return
|
||||||
const el = document.activeElement
|
const el = document.activeElement
|
||||||
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
|
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
|
||||||
const s = useEditorStore.getState()
|
const s = useEditorStore.getState()
|
||||||
if (s.objectDragging) return // don't fight an active pointer drag
|
if (s.selectedId == null && s.selectedPlantingId == null) return
|
||||||
|
e.preventDefault()
|
||||||
const step = e.shiftKey ? 10 : 1
|
const step = e.shiftKey ? 10 : 1
|
||||||
if (s.selectedId != null) {
|
nudgeSelected(dir[0] * step, dir[1] * step)
|
||||||
const base = s.liveObject?.id === s.selectedId ? s.liveObject : objs.find((o) => o.id === s.selectedId)
|
|
||||||
if (!base) return
|
|
||||||
e.preventDefault()
|
|
||||||
s.setLiveObject({ ...base, xCm: base.xCm + dir[0] * step, yCm: base.yCm + dir[1] * step })
|
|
||||||
commitLater(() => {
|
|
||||||
const live = useEditorStore.getState().liveObject
|
|
||||||
if (live?.id !== s.selectedId) return // a drag cleared it and committed
|
|
||||||
uo.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
|
|
||||||
useEditorStore.getState().setLiveObject(null)
|
|
||||||
})
|
|
||||||
} else if (s.selectedPlantingId != null) {
|
|
||||||
const base =
|
|
||||||
s.livePlanting?.id === s.selectedPlantingId ? s.livePlanting : plops.find((p) => p.id === s.selectedPlantingId)
|
|
||||||
if (!base) return
|
|
||||||
e.preventDefault()
|
|
||||||
const obj = objs.find((o) => o.id === base.objectId)
|
|
||||||
let nx = base.xCm + dir[0] * step
|
|
||||||
let ny = base.yCm + dir[1] * step
|
|
||||||
if (obj) {
|
|
||||||
nx = Math.max(-obj.widthCm / 2, Math.min(obj.widthCm / 2, nx))
|
|
||||||
ny = Math.max(-obj.heightCm / 2, Math.min(obj.heightCm / 2, ny))
|
|
||||||
}
|
|
||||||
s.setLivePlanting({ ...base, xCm: nx, yCm: ny })
|
|
||||||
commitLater(() => {
|
|
||||||
const live = useEditorStore.getState().livePlanting
|
|
||||||
if (live?.id !== s.selectedPlantingId) return
|
|
||||||
up.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
|
|
||||||
useEditorStore.getState().setLivePlanting(null)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => {
|
return () => {
|
||||||
@@ -483,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>
|
||||||
@@ -503,6 +531,8 @@ export function GardenEditorPage() {
|
|||||||
objects={objects}
|
objects={objects}
|
||||||
scopeObjectId={journalObjectId}
|
scopeObjectId={journalObjectId}
|
||||||
onScopeChange={setJournalObjectId}
|
onScopeChange={setJournalObjectId}
|
||||||
|
scopePlantingId={journalPlantingId}
|
||||||
|
onScopePlantingChange={setJournalPlantingId}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -526,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). */}
|
||||||
@@ -579,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>
|
||||||
@@ -595,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
|
||||||
@@ -619,6 +667,8 @@ export function GardenEditorPage() {
|
|||||||
onClear={() => setClearing(true)}
|
onClear={() => setClearing(true)}
|
||||||
onFill={fillBed}
|
onFill={fillBed}
|
||||||
filling={fillObject.isPending}
|
filling={fillObject.isPending}
|
||||||
|
canScan={canScan}
|
||||||
|
onScan={() => setScanning(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted">Not plantable</span>
|
<span className="text-xs text-muted">Not plantable</span>
|
||||||
@@ -627,6 +677,13 @@ export function GardenEditorPage() {
|
|||||||
)}
|
)}
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
||||||
|
{/* Touch fine-positioning: the keyboard's arrow-nudge has no equivalent
|
||||||
|
on a touch device, and dragging can't hit single-cm precision. Shown
|
||||||
|
on a coarse pointer (same signal as the bigger handles) while
|
||||||
|
something's selected (#104). */}
|
||||||
|
{canEdit && isCoarsePointer && (selectedId != null || selectedPlantingId != null) && (
|
||||||
|
<NudgePad onNudge={nudgeSelected} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty-state hints (non-interactive overlays). */}
|
{/* Empty-state hints (non-interactive overlays). */}
|
||||||
@@ -646,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
|
||||||
@@ -686,6 +746,8 @@ export function GardenEditorPage() {
|
|||||||
onClear={() => setClearing(true)}
|
onClear={() => setClearing(true)}
|
||||||
onFill={fillBed}
|
onFill={fillBed}
|
||||||
filling={fillObject.isPending}
|
filling={fillObject.isPending}
|
||||||
|
canScan={!!capabilities.data?.vision}
|
||||||
|
onScan={() => setScanning(true)}
|
||||||
/>
|
/>
|
||||||
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
|
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
|
||||||
Done planting
|
Done planting
|
||||||
@@ -704,7 +766,18 @@ export function GardenEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<p className="px-1 text-xs text-muted">Tap a bed, then “🌱 Plant here” to start planting.</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="px-1 text-xs text-muted">Tap a bed, then “🌱 Plant here” to start planting.</p>
|
||||||
|
{canScan && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="ml-auto px-2 py-1 text-xs"
|
||||||
|
onClick={() => setScanning(true)}
|
||||||
|
>
|
||||||
|
📷 Scan packet
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -721,6 +794,8 @@ export function GardenEditorPage() {
|
|||||||
|
|
||||||
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(false)} />}
|
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(false)} />}
|
||||||
|
|
||||||
|
{scanning && <ScanPacketModal unit={garden.unitPref} onClose={() => setScanning(false)} />}
|
||||||
|
|
||||||
{clearing && focusedObject && (
|
{clearing && focusedObject && (
|
||||||
<ClearBedModal
|
<ClearBedModal
|
||||||
objectId={focusedObject.id}
|
objectId={focusedObject.id}
|
||||||
@@ -748,6 +823,8 @@ function PlantPlacementTools({
|
|||||||
onClear,
|
onClear,
|
||||||
onFill,
|
onFill,
|
||||||
filling,
|
filling,
|
||||||
|
canScan,
|
||||||
|
onScan,
|
||||||
}: {
|
}: {
|
||||||
recentPlants: Plant[]
|
recentPlants: Plant[]
|
||||||
trayPlants: Plant[]
|
trayPlants: Plant[]
|
||||||
@@ -760,6 +837,10 @@ function PlantPlacementTools({
|
|||||||
onClear: () => void
|
onClear: () => void
|
||||||
onFill: (layout: FillLayout) => void
|
onFill: (layout: FillLayout) => void
|
||||||
filling: boolean
|
filling: boolean
|
||||||
|
// Scanning a packet adds a variety to the catalog mid-planting; only offered
|
||||||
|
// where a vision model is configured.
|
||||||
|
canScan: boolean
|
||||||
|
onScan: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -771,6 +852,11 @@ function PlantPlacementTools({
|
|||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
onOpenPicker={onOpenPicker}
|
onOpenPicker={onOpenPicker}
|
||||||
/>
|
/>
|
||||||
|
{canScan && (
|
||||||
|
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onScan}>
|
||||||
|
📷 Scan packet
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{armedPlant && <FillControl onFill={onFill} busy={filling} />}
|
{armedPlant && <FillControl onFill={onFill} busy={filling} />}
|
||||||
{armedPlant && (
|
{armedPlant && (
|
||||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onDisarm}>
|
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onDisarm}>
|
||||||
@@ -822,6 +908,41 @@ function FillControl({ onFill, busy }: { onFill: (layout: FillLayout) => void; b
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On-screen nudge pad (#104): 1cm arrows for the selected object/plop on a touch
|
||||||
|
// device, where the keyboard's arrow-nudge isn't reachable and a drag can't hit
|
||||||
|
// single-cm precision. Rendered only on a coarse pointer (the caller gates it).
|
||||||
|
// Wired to the same nudgeSelected, so it shares the live-then-debounced-PATCH.
|
||||||
|
function NudgePad({ onNudge }: { onNudge: (dx: number, dy: number) => void }) {
|
||||||
|
const btn =
|
||||||
|
'flex size-10 items-center justify-center rounded-md border border-border bg-surface/90 text-fg ' +
|
||||||
|
'shadow-sm outline-none backdrop-blur transition-colors active:bg-border/70 focus-visible:ring-2 focus-visible:ring-accent/40'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="Nudge selection by 1cm"
|
||||||
|
className="absolute bottom-2 left-2 z-20 grid grid-cols-3 grid-rows-3 gap-0.5"
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<button type="button" className={btn} aria-label="Nudge up" onClick={() => onNudge(0, -1)}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<span />
|
||||||
|
<button type="button" className={btn} aria-label="Nudge left" onClick={() => onNudge(-1, 0)}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span className="flex size-10 items-center justify-center text-[0.6rem] font-medium text-muted">1cm</span>
|
||||||
|
<button type="button" className={btn} aria-label="Nudge right" onClick={() => onNudge(1, 0)}>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
<span />
|
||||||
|
<button type="button" className={btn} aria-label="Nudge down" onClick={() => onNudge(0, 1)}>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// The mobile primary mode switch (#99): one always-there tab bar so "placing
|
// The mobile primary mode switch (#99): one always-there tab bar so "placing
|
||||||
// beds", "planting", "journaling" and "assistant" stop competing for the same
|
// beds", "planting", "journaling" and "assistant" stop competing for the same
|
||||||
// strip. Assistant is dropped when the instance has no model configured.
|
// strip. Assistant is dropped when the instance has no model configured.
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { PlantFormModal } from '@/components/plants/PlantFormModal'
|
|||||||
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
|
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
|
||||||
import { SeedLotModal } from '@/components/plants/SeedLotModal'
|
import { SeedLotModal } from '@/components/plants/SeedLotModal'
|
||||||
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
|
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
|
||||||
|
import { ScanPacketModal } from '@/components/plants/ScanPacketModal'
|
||||||
import { PlantPicker } from '@/editor/PlantPicker'
|
import { PlantPicker } from '@/editor/PlantPicker'
|
||||||
|
import { useCapabilities } from '@/lib/agent'
|
||||||
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
||||||
import { lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
import { lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||||
import type { UnitPref } from '@/lib/units'
|
import type { UnitPref } from '@/lib/units'
|
||||||
@@ -22,6 +24,7 @@ type Dialog =
|
|||||||
| { kind: 'duplicate'; plant: Plant }
|
| { kind: 'duplicate'; plant: Plant }
|
||||||
| { kind: 'delete'; plant: Plant }
|
| { kind: 'delete'; plant: Plant }
|
||||||
| { kind: 'picker' }
|
| { kind: 'picker' }
|
||||||
|
| { kind: 'scan' }
|
||||||
| { kind: 'addLot'; plant: Plant }
|
| { kind: 'addLot'; plant: Plant }
|
||||||
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
|
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
|
||||||
| { kind: 'deleteLot'; lot: SeedLot }
|
| { kind: 'deleteLot'; lot: SeedLot }
|
||||||
@@ -41,6 +44,7 @@ function loadUnit(): UnitPref {
|
|||||||
export function PlantsPage() {
|
export function PlantsPage() {
|
||||||
usePageTitle('Plants')
|
usePageTitle('Plants')
|
||||||
const plants = usePlants()
|
const plants = usePlants()
|
||||||
|
const capabilities = useCapabilities()
|
||||||
const seedLots = useSeedLots()
|
const seedLots = useSeedLots()
|
||||||
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||||
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
|
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
|
||||||
@@ -72,6 +76,13 @@ export function PlantsPage() {
|
|||||||
<Button variant="ghost" onClick={() => setDialog({ kind: 'picker' })}>
|
<Button variant="ghost" onClick={() => setDialog({ kind: 'picker' })}>
|
||||||
Try the picker
|
Try the picker
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Only where a vision model is configured — otherwise the scan would
|
||||||
|
404 on the vision call, so we don't offer it. */}
|
||||||
|
{capabilities.data?.vision && (
|
||||||
|
<Button variant="ghost" onClick={() => setDialog({ kind: 'scan' })}>
|
||||||
|
Scan a packet
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={toggleUnit}
|
onClick={toggleUnit}
|
||||||
@@ -136,6 +147,7 @@ export function PlantsPage() {
|
|||||||
{dialog?.kind === 'addLot' && <SeedLotModal plant={dialog.plant} onClose={close} />}
|
{dialog?.kind === 'addLot' && <SeedLotModal plant={dialog.plant} onClose={close} />}
|
||||||
{dialog?.kind === 'editLot' && <SeedLotModal plant={dialog.plant} lot={dialog.lot} onClose={close} />}
|
{dialog?.kind === 'editLot' && <SeedLotModal plant={dialog.plant} lot={dialog.lot} onClose={close} />}
|
||||||
{dialog?.kind === 'deleteLot' && <DeleteSeedLotModal lot={dialog.lot} onClose={close} />}
|
{dialog?.kind === 'deleteLot' && <DeleteSeedLotModal lot={dialog.lot} onClose={close} />}
|
||||||
|
{dialog?.kind === 'scan' && <ScanPacketModal unit={unit} onClose={close} />}
|
||||||
{dialog?.kind === 'picker' && (
|
{dialog?.kind === 'picker' && (
|
||||||
<PlantPicker
|
<PlantPicker
|
||||||
unit={unit}
|
unit={unit}
|
||||||
|
|||||||
+1
-33
@@ -1,4 +1,3 @@
|
|||||||
import { lazy, type ComponentType } from 'react'
|
|
||||||
import {
|
import {
|
||||||
createRootRouteWithContext,
|
createRootRouteWithContext,
|
||||||
createRoute,
|
createRoute,
|
||||||
@@ -15,38 +14,7 @@ import { meQueryOptions } from '@/lib/auth'
|
|||||||
import { queryClient } from '@/lib/queryClient'
|
import { queryClient } from '@/lib/queryClient'
|
||||||
import { safeRedirectPath } from '@/lib/redirect'
|
import { safeRedirectPath } from '@/lib/redirect'
|
||||||
import { getLastGardenId } from '@/lib/lastGarden'
|
import { getLastGardenId } from '@/lib/lastGarden'
|
||||||
|
import { lazyPage } from '@/lib/lazyPage'
|
||||||
// Lazily load a page by its named export, with recovery for the stale-chunk
|
|
||||||
// problem. A push to main redeploys, so a still-open app references chunk hashes
|
|
||||||
// the server has just replaced; that import 404s and React.lazy MEMOIZES the
|
|
||||||
// rejection, so RouteError's "Try again" (router.invalidate) can never recover —
|
|
||||||
// the user is stuck until a manual hard reload. On the first such failure we
|
|
||||||
// reload once (fetching the fresh index + hashes); a session flag stops a reload
|
|
||||||
// loop, and a success clears it so a later genuine failure can reload again.
|
|
||||||
function lazyPage<M, K extends keyof M>(load: () => Promise<M>, name: K) {
|
|
||||||
return lazy(async () => {
|
|
||||||
try {
|
|
||||||
const mod = await load()
|
|
||||||
try {
|
|
||||||
sessionStorage.removeItem('pansy:chunk-reload')
|
|
||||||
} catch {
|
|
||||||
/* storage unavailable — fine */
|
|
||||||
}
|
|
||||||
return { default: mod[name] as ComponentType }
|
|
||||||
} catch (err) {
|
|
||||||
try {
|
|
||||||
if (!sessionStorage.getItem('pansy:chunk-reload')) {
|
|
||||||
sessionStorage.setItem('pansy:chunk-reload', '1')
|
|
||||||
window.location.reload()
|
|
||||||
return await new Promise<{ default: ComponentType }>(() => {}) // hold for the reload
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* storage unavailable — fall through to surface the error */
|
|
||||||
}
|
|
||||||
throw err // already reloaded once (or can't); let the error boundary show it
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code-split the heavier / deeper routes so a phone on cell data doesn't download
|
// Code-split the heavier / deeper routes so a phone on cell data doesn't download
|
||||||
// the whole app (notably the canvas editor with its gesture + geometry deps)
|
// the whole app (notably the canvas editor with its gesture + geometry deps)
|
||||||
|
|||||||
+17
-14
@@ -31,22 +31,25 @@ export default defineConfig(({ mode }) => {
|
|||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
// One vendor chunk for ALL node_modules: it's rarely-changing, so it
|
// The app-wide core goes in ONE cached vendor chunk (rarely changes, so
|
||||||
// caches across app deploys while the tiny app chunk churns. Kept as a
|
// it survives deploys while the tiny app chunk churns). react + react-dom
|
||||||
// SINGLE chunk on purpose — splitting react-dom/scheduler into their own
|
// + scheduler MUST stay together — splitting react-dom into its own chunk
|
||||||
// chunk reorders their module init across chunk boundaries and breaks
|
// reorders module init across chunk boundaries and breaks React 19 at
|
||||||
// React 19 at load ("Cannot set 'Activity' of undefined"). The heavy
|
// load ("Cannot set 'Activity' of undefined"). Everything else — the
|
||||||
// routes are code-split separately via React.lazy (router.tsx), which is
|
// gesture engine, the assistant's markdown renderer + its ecosystem —
|
||||||
// where the real first-paint win is.
|
// rides with whatever imports it, so a lazily-loaded route/feature keeps
|
||||||
|
// it out of the eager first paint. The routes are code-split via
|
||||||
|
// React.lazy (router.tsx), where the real first-paint win is.
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
if (!id.includes('node_modules')) return undefined
|
if (!id.includes('node_modules')) return undefined
|
||||||
// Editor-only libs (the gesture engine) ride with the lazy editor
|
if (
|
||||||
// chunk instead of the eager vendor one, so the first paint doesn't
|
/[/\\]node_modules[/\\](react|react-dom|scheduler|@tanstack|zustand|zod|clsx|tailwind-merge)[/\\]/.test(
|
||||||
// pay for code only the canvas needs. Everything else — react, tanstack
|
id,
|
||||||
// and the rest — stays in ONE vendor chunk; splitting react-dom out
|
)
|
||||||
// reorders its init across chunks and breaks React 19 at load.
|
) {
|
||||||
if (id.includes('@use-gesture')) return undefined
|
return 'vendor'
|
||||||
return 'vendor'
|
}
|
||||||
|
return undefined
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user