@montytools/cli 0.2.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ // The ONE config-compile pipeline: esbuild-bundle a temp entry that runs the
2
+ // app's OWN compileApp/zod/sdk instances on monty.config.ts, execute it in a
3
+ // subprocess, parse the emitted metadata. Used by `monty dev`/`monty deploy`
4
+ // (bin/monty.mjs) and the demo harness (scripts/demo.mjs) — one pipeline, so
5
+ // what a demo installs is byte-for-byte what a deploy would send.
6
+ import { spawnSync } from "node:child_process";
7
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+
10
+ export class CompileError extends Error {
11
+ constructor(code, fix) {
12
+ super(`[${code}] ${fix}`);
13
+ this.code = code;
14
+ this.fix = fix;
15
+ }
16
+ }
17
+
18
+ // Compile appDir/monty.config.ts → { slug, name, icon?, schemaJson, exposure? }.
19
+ // Throws CompileError:
20
+ // - CONFIG_BUNDLE_FAILED — esbuild could not bundle (usually unlinked deps)
21
+ // - CONFIG_COMPILE_FAILED — the config threw while loading
22
+ export async function compileAppConfig(appDir) {
23
+ appDir = resolve(appDir); // esbuild requires an absolute absWorkingDir
24
+ // esbuild is a dependency of THIS package (@montytools/cli), so resolution
25
+ // from here works for any caller — no per-script resolution dance.
26
+ const { build } = await import("esbuild");
27
+ const tmpDir = join(appDir, ".monty");
28
+ mkdirSync(tmpDir, { recursive: true });
29
+ const entry = join(tmpDir, "compile-entry.mjs");
30
+ const out = join(tmpDir, "compile-out.mjs");
31
+ writeFileSync(entry, [
32
+ `import { app } from "../monty.config";`,
33
+ `import { compileApp } from "@montytools/sdk/compile";`,
34
+ `process.stdout.write(JSON.stringify(compileApp(app)));`,
35
+ ].join("\n"));
36
+ try {
37
+ await build({
38
+ entryPoints: [entry],
39
+ outfile: out,
40
+ bundle: true,
41
+ platform: "node",
42
+ format: "esm",
43
+ target: "node22",
44
+ absWorkingDir: appDir,
45
+ logLevel: "silent",
46
+ });
47
+ const result = spawnSync(process.execPath, [out], { encoding: "utf8" });
48
+ if (result.status !== 0) {
49
+ throw new CompileError(
50
+ "CONFIG_COMPILE_FAILED",
51
+ `monty.config.ts threw while loading:\n${result.stderr}\nFix the config (it must only call defineApp with zod tables).`,
52
+ );
53
+ }
54
+ return JSON.parse(result.stdout);
55
+ } catch (e) {
56
+ if (e?.errors) {
57
+ throw new CompileError(
58
+ "CONFIG_BUNDLE_FAILED",
59
+ `esbuild could not bundle monty.config.ts: ${e.errors[0]?.text ?? e.message}\nRun \`pnpm install\` so the app's deps link, then retry.`,
60
+ );
61
+ }
62
+ throw e;
63
+ } finally {
64
+ rmSync(entry, { force: true });
65
+ rmSync(out, { force: true });
66
+ }
67
+ }
package/package.json CHANGED
@@ -1,12 +1,18 @@
1
1
  {
2
2
  "name": "@montytools/cli",
3
- "version": "0.2.9",
3
+ "version": "0.4.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/TomasMonty/monty-v2.git",
7
+ "directory": "packages/cli"
8
+ },
4
9
  "type": "module",
5
10
  "bin": {
6
11
  "monty": "./bin/monty.mjs"
7
12
  },
8
13
  "files": [
9
14
  "bin",
15
+ "lib",
10
16
  "template",
11
17
  "skills"
12
18
  ],
@@ -15,7 +21,7 @@
15
21
  },
16
22
  "scripts": {
17
23
  "prepack": "node scripts/bundle-template.mjs",
18
- "typecheck": "node --check bin/monty.mjs",
24
+ "typecheck": "node --check bin/monty.mjs && node --check lib/compile.mjs",
19
25
  "postinstall": "node bin/postinstall.mjs"
20
26
  },
21
27
  "dependencies": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: monty-build
3
- description: Build, run, and deploy Monty workspace apps. Use whenever the task involves a Monty app, monty.config.ts, the monty CLI (create/dev/deploy/add), the @montytools/sdk, or a prompt mentioning usemonty.dev. Covers folder discipline, the build loop, data/auth rules, and error handling.
3
+ description: Build, run, and deploy Monty workspace apps. Use whenever the task involves a Monty app, monty.config.ts, the monty CLI (create/dev/logs/deploy/add), the @montytools/sdk, or a prompt mentioning usemonty.dev. Covers folder discipline, the build loop, data/auth rules, and error handling.
4
4
  ---
5
5
 
6
6
  # Building Monty apps
@@ -11,25 +11,43 @@ platform's job. The complete contract lives in the app's own `AGENTS.md`
11
11
  (nearest-file-wins — read it before writing code). This skill is the map, not
12
12
  the territory.
13
13
 
14
+ Every app has two states: **Live** (published — what members and visitors
15
+ see) and **Studio** (development — a running dev shell on `#dev`-sandboxed
16
+ data, often already started for you by the Monty desktop). Those are the only
17
+ names for them; "dev"/"prod" mean something else on this platform.
18
+
14
19
  ## Rules
15
20
 
16
- 1. **Folders are managed.** Apps live in `~/Monty/<slug>`. `monty current`
17
- tells you where you are; `cd "$(monty select <slug>)"` jumps to an app;
18
- `monty apps` lists local ones. Never mkdir app folders by hand.
19
- 2. **The loop:** `monty create <slug> --name "Name" --icon <lucide-icon>`
21
+ 1. **Folders are managed.** Apps live in `~/.monty/apps/<id>` (a server-issued
22
+ id, minted when `monty create` registers the app so create needs
23
+ `monty login` first). `monty current` tells you where you are;
24
+ `cd "$(monty select <slug>)"` jumps to an app; `monty apps` lists local
25
+ ones. Never mkdir app folders by hand, and never edit the `id:` line in
26
+ `monty.config.ts`.
27
+ 2. **The loop:** `monty create <slug> --name "Name" --icon <tabler-icon>` →
20
28
  (if the prompt includes a `build id`, pass it: `--build <id>` — the
21
29
  workspace's New app screen tracks your progress live) →
22
30
  `monty install` → edit `monty.config.ts` (zod tables) + `src/routes/` →
23
- verify with `monty dev` (auto-port, already authenticated, sandboxed
24
- data). **You are done when `monty dev` prints the workspace dev URL and
25
- the app works leave `monty dev` running.** Publishing to the whole
26
- workspace is the OWNER'S click (Publish in the workspace menu bar);
27
- **never run `monty deploy` yourself** unless the user explicitly asks
28
- for a direct production deploy.
31
+ verify in Studio: run `monty dev` once if the dev shell is already
32
+ running (the Monty desktop usually runs it for you) it prints the status,
33
+ Studio URL, and recent log lines, then **exits immediately**; if nothing
34
+ is running it starts the shell (start it in the background and move on).
35
+ Then iterate: edit code vite hot-reloads `monty logs -n 50` shows
36
+ whether it compiled and any browser errors. **You are done when
37
+ `monty dev` reports `state: online` (or prints the Studio URL on a fresh
38
+ start) and the app works.** Re-running `monty dev` is always safe — it
39
+ attaches, prints status, and exits. Never try to run a second dev
40
+ *server* for the same app (attach handles this for you) and never kill a
41
+ dev shell you didn't start; `monty dev --takeover` is the only sanctioned
42
+ restart when a session is wedged. Taking the app Live is the OWNER'S
43
+ click (Publish in the workspace menu bar); **never run `monty deploy`
44
+ yourself** unless the user explicitly asks for a direct Live deploy.
29
45
  3. **Everything through the CLI.** `monty install`, `monty build`,
30
46
  `monty typecheck`, `monty dev`, `monty deploy` — never run vite, tsc,
31
47
  pnpm, or npm scripts directly. `monty dev` auto-picks a free port and
32
- prints it; `monty typecheck` builds first when needed.
48
+ prints it; `monty typecheck` builds first when needed. `monty logs`
49
+ (add `-f` to follow) is how you read the dev shell's output — vite build
50
+ errors, browser errors, and publish results all land there.
33
51
  4. **One import surface:** `@montytools/sdk` (`defineApp`, zod) and
34
52
  `@montytools/sdk/react` (hooks: `useList`, `useInsert`, …). Never import
35
53
  Clerk or Convex directly; never fetch external APIs from app code — the
@@ -41,18 +59,20 @@ the territory.
41
59
  7. **Errors are instructions.** Every failure prints
42
60
  `[MontyError CODE] Fix: …` — do exactly what the Fix says; don't guess.
43
61
  Typecheck failures block deploy by design.
44
- 8. **Verify before deploy.** `monty dev` writes to a `#dev` sandbox — live
45
- team records are never touched, so exercise the app for real.
62
+ 8. **Verify before publish.** The dev shell writes to the `#dev` Studio
63
+ sandbox — Live team records are never touched, so exercise the app for
64
+ real.
46
65
 
47
66
  ## CLI reference
48
67
 
49
68
  | command | purpose |
50
69
  |---|---|
51
70
  | `monty login` | browser sign-in (loopback authorize), once per machine |
52
- | `monty create <slug>` | stamp a new app into `~/Monty/<slug>` |
71
+ | `monty create <slug>` | register the app in the workspace + stamp it into `~/.monty/apps/<id>` (needs login) |
53
72
  | `monty current` / `select` / `apps` | where am I / jump to app / list local |
54
73
  | `monty install` / `build` / `typecheck` | full lifecycle via the CLI — no raw pnpm/vite/tsc |
55
- | `monty dev` | run locally (auto-picks a free port), sandboxed data, auto-auth |
74
+ | `monty dev` | run the app in Studio, or attach to an already-running session (auto-port, sandboxed data, auto-auth) |
75
+ | `monty logs [-n N] [-f]` | read/follow the dev shell log — the debugging window after every edit |
56
76
  | `monty add <name…>` | install curated shadcn components |
57
- | `monty deploy` | build + typecheck + upload; app appears in the workspace |
77
+ | `monty deploy` | build + typecheck + upload straight to Live (owner escape hatch) |
58
78
  | `monty skills` | (re)install this skill for your agent |
@@ -0,0 +1,5 @@
1
+ {
2
+ "permissions": {
3
+ "allow": ["Bash(monty:*)", "Edit(**)", "Write(**)"]
4
+ }
5
+ }
@@ -10,8 +10,8 @@ Folders are managed for you: this app lives in `~/Monty/<slug>`. `monty current`
10
10
 
11
11
  | File | What it is |
12
12
  |---|---|
13
- | `monty.config.ts` | Your data schema — plain zod. The ONLY place data shapes are defined. Also `name` + `icon` (any [lucide](https://lucide.dev/icons) icon name, e.g. `"receipt"`, `"users"`) — Monty renders your app's logo tile from it. |
14
- | `src/routes/` | Your UI — TanStack Router file routes (`index.tsx` = `/`). |
13
+ | `monty.config.ts` | Your data schema — plain zod. The ONLY place data shapes are defined. Also `name` + `icon` (any [Tabler](https://tabler.io/icons) icon name, e.g. `"receipt"`, `"users"`) — Monty renders your app's logo tile from it. |
14
+ | `src/routes/` | Your UI — TanStack Router file routes (`index.tsx` = `/`). The starter `index.tsx` is a blank-canvas placeholder — replace it with the real app. |
15
15
  | `src/main.tsx` | Wiring. Do not edit. |
16
16
 
17
17
  ## Rules (violations break the app)
@@ -110,6 +110,63 @@ Every platform error is one line shaped like:
110
110
  | `UNAUTHENTICATED` / `NO_ACTIVE_WORKSPACE` | App isn't running through the Monty host/dev shell. |
111
111
  | `MISSING_ENV` / `NO_PROVIDER` | `.env.local` or the `<MontyProvider>` in `main.tsx` was removed. |
112
112
 
113
+ ## Server code (optional): functions, public endpoints, schedules
114
+
115
+ When logic must not run in the browser (private tables, third-party APIs with
116
+ secret keys, webhooks, clocks), create `server/index.ts` with named async
117
+ exports. Web-standard APIs only (`fetch`, `crypto`, `URL`, …) — no `node:`
118
+ imports; it runs on Cloudflare Workers when Live and inside `monty dev` in
119
+ Studio. Every export gets `ctx`: `ctx.records` (full CRUD on all your
120
+ tables, including ones the UI never exposes), `ctx.secrets` (see below),
121
+ `ctx.viewer` (who called: member session/visitor/schedule/none), and
122
+ `ctx.track()` (emit an event).
123
+
124
+ ```ts
125
+ // server/index.ts
126
+ import type { MontyFnContext, MontyPublicRequest } from "@montytools/sdk/server";
127
+
128
+ // 1) App-called: invoke from the UI with useServerFn.
129
+ export async function score(args: { sessionId: string }, ctx: MontyFnContext) {
130
+ const weights = await ctx.records.list("scoring"); // hidden table — fine here
131
+ return { verdict: weights.length > 0 ? "ok" : "empty" };
132
+ }
133
+
134
+ // 2) PUBLIC endpoint — declared in monty.config.ts `publicFns: ["stripe"]`,
135
+ // then ANYONE on the internet can call GET/POST /__monty/public/stripe.
136
+ // Verify a signature from ctx.secrets against the RAW req.body before
137
+ // trusting anything; return { status, body?, contentType? } to control the
138
+ // response (or return data for 200 JSON, or nothing for {"ok":true}).
139
+ export async function stripe(req: MontyPublicRequest, ctx: MontyFnContext) {
140
+ if (!verify(req.headers["stripe-signature"], req.body, ctx.secrets.STRIPE_WEBHOOK_SECRET)) {
141
+ return { status: 401, body: "bad signature" };
142
+ }
143
+ await ctx.records.insert("payments", JSON.parse(req.body));
144
+ return { status: 200, body: "ok" };
145
+ }
146
+
147
+ // 3) SCHEDULED — declared in monty.config.ts `schedule: { digest: "0 9 * * *" }`
148
+ // (5-field cron, UTC; max 3). Runs as digest({}, ctx) with
149
+ // ctx.viewer = { lane: "schedule", cron }. Make it idempotent — re-runs happen.
150
+ export async function digest(_args: Record<string, unknown>, ctx: MontyFnContext) { /* … */ }
151
+
152
+ // 4) onEvent — reserved name: the platform calls it after every accepted
153
+ // track() event. Forward to Slack/Meta/PostHog/your CRM here, with your keys.
154
+ export async function onEvent(event: { name: string }, ctx: MontyFnContext) { /* … */ }
155
+ ```
156
+
157
+ From the UI: `const score = useServerFn<Result>(app, "score"); await score({ sessionId })`.
158
+ Public functions are NOT callable through `useServerFn` — only at their
159
+ `/__monty/public/<name>` URL (and vice versa: undeclared functions are never
160
+ public).
161
+
162
+ **Secrets:** `monty secret set STRIPE_KEY` stores a key for Live (write-only,
163
+ never in code or config). In Studio, put the same names in the gitignored
164
+ `.monty/secrets.json`. Both arrive as `ctx.secrets.STRIPE_KEY`.
165
+
166
+ **Studio behavior:** `monty dev` runs your schedules for real (a `cron:` line
167
+ prints per firing, UTC) and serves public functions at
168
+ `http://localhost:<port>/__monty/public/<name>` — curl them to test.
169
+
113
170
  ## Dev loop
114
171
 
115
172
  Everything goes through the `monty` CLI — never run vite, tsc, pnpm, or npm
@@ -123,11 +180,12 @@ monty dev # Vite + HMR, auto-picks a free port and prints it
123
180
  Headless? Verify with `monty build` then `monty typecheck` (typecheck builds
124
181
  first when needed — the build generates `src/routeTree.gen.ts`).
125
182
 
126
- **Development vs production:** while `monty dev` runs, the app is live in
127
- the workspace in DEV mode (workspace admins only, tunneled, `#dev` sandboxed
128
- data). Shipping to the whole team is the owner's **Publish** click in the
183
+ **Studio vs Live:** every Monty app has two states. While `monty dev` runs,
184
+ the app is in **Studio** visible in the workspace to admins only
185
+ (tunneled, `#dev` sandboxed data). **Live** is the published state the
186
+ whole team sees. Going Live is the owner's **Publish** click in the
129
187
  workspace menu bar — it signals your running `monty dev`, which builds,
130
- typechecks, and uploads. You are done when the app works in dev mode;
188
+ typechecks, and uploads. You are done when the app works in Studio;
131
189
  leave `monty dev` running and let the owner publish. Only run
132
190
  `monty deploy` directly if the user explicitly asks.
133
191
 
@@ -135,12 +193,12 @@ leave `monty dev` running and let the owner publish. Only run
135
193
  `http://localhost:5173` is ALREADY AUTHENTICATED — no sign-in screen (the dev
136
194
  server mints short-lived workspace tokens from the CLI login). Point
137
195
  Playwright or any browser automation at it, click through your app against
138
- live sandboxed data, and read your errors in the `monty dev` terminal
196
+ reactive sandboxed data, and read your errors in the `monty dev` terminal
139
197
  (`[browser:error] …` lines). Edit → HMR → look → fix: verify your own work.
140
198
 
141
- Dev writes go to a sandboxed `#dev` namespace inside your real workspace —
142
- iterate freely, live app data is untouched. The "DEV · … · sandbox data" badge
143
- confirms it.
199
+ Studio writes go to a sandboxed `#dev` namespace inside your real workspace —
200
+ iterate freely, Live app data is untouched. The "STUDIO · … · sandbox data"
201
+ badge confirms it.
144
202
 
145
203
  ## Modeling tips
146
204
 
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Expenses</title>
6
+ <title>New app</title>
7
7
  </head>
8
8
  <body>
9
9
  <div id="root"></div>
@@ -1,20 +1,21 @@
1
- import { z } from "zod";
2
1
  import { defineApp } from "@montytools/sdk";
3
2
 
4
3
  // This file is the ONLY place data shapes are defined. Edit it, save, and the
5
- // typed hooks in your components update immediately.
4
+ // typed hooks in your components update immediately. The starter ships with no
5
+ // tables — declare each one as a zod object as the app needs it, e.g.:
6
+ //
7
+ // import { z } from "zod";
8
+ // tables: {
9
+ // tasks: z.object({
10
+ // title: z.string().min(1),
11
+ // done: z.boolean().default(false),
12
+ // }),
13
+ // },
6
14
  export const app = defineApp({
7
- slug: "expenses",
8
- name: "Expenses",
9
- icon: "receipt",
10
- tables: {
11
- expenses: z.object({
12
- title: z.string().min(1),
13
- amount: z.number().positive(),
14
- status: z.enum(["draft", "submitted", "approved"]).default("draft"),
15
- assigneeId: z.string().optional(), // a workspace member's userId (from useMembers)
16
- }),
17
- },
15
+ slug: "new-app",
16
+ name: "New app",
17
+ icon: "layout-grid",
18
+ tables: {},
18
19
  });
19
20
 
20
21
  export type App = typeof app;
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@fontsource-variable/roboto": "^5.2.10",
13
- "@montytools/sdk": "^0.1.2",
13
+ "@montytools/sdk": "^0.1.4",
14
14
  "@tanstack/react-router": "1.170.17",
15
15
  "class-variance-authority": "^0.7.1",
16
16
  "clsx": "^2.1.1",
@@ -1,263 +1,38 @@
1
- import { useState } from "react";
2
1
  import { createFileRoute } from "@tanstack/react-router";
3
- import { CheckIcon, PlusIcon, TrashIcon } from "lucide-react";
2
+ import { Sparkles } from "lucide-react";
4
3
 
5
- import {
6
- useInsert,
7
- useList,
8
- useMembers,
9
- useRemove,
10
- useUpdate,
11
- } from "@montytools/sdk/react";
12
- import { Badge } from "@/components/ui/badge";
13
- import { Button } from "@/components/ui/button";
14
- import {
15
- Card,
16
- CardContent,
17
- CardHeader,
18
- CardTitle,
19
- } from "@/components/ui/card";
20
4
  import {
21
5
  Empty,
22
6
  EmptyDescription,
23
7
  EmptyHeader,
8
+ EmptyMedia,
24
9
  EmptyTitle,
25
10
  } from "@/components/ui/empty";
26
- import {
27
- Field,
28
- FieldError,
29
- FieldGroup,
30
- FieldLabel,
31
- } from "@/components/ui/field";
32
- import { Input } from "@/components/ui/input";
33
- import {
34
- Select,
35
- SelectContent,
36
- SelectGroup,
37
- SelectItem,
38
- SelectTrigger,
39
- SelectValue,
40
- } from "@/components/ui/select";
41
- import { Spinner } from "@/components/ui/spinner";
42
- import {
43
- Table,
44
- TableBody,
45
- TableCell,
46
- TableHead,
47
- TableHeader,
48
- TableRow,
49
- } from "@/components/ui/table";
50
11
 
51
12
  import { app } from "../../monty.config";
52
13
 
53
14
  export const Route = createFileRoute("/")({
54
- component: ExpenseBoard,
15
+ component: BlankCanvas,
55
16
  });
56
17
 
57
- type Status = "draft" | "submitted" | "approved";
58
- const NEXT_STATUS: Record<Status, Status | null> = {
59
- draft: "submitted",
60
- submitted: "approved",
61
- approved: null,
62
- };
63
- const STATUS_VARIANT = {
64
- draft: "secondary",
65
- submitted: "default",
66
- approved: "outline",
67
- } as const;
68
-
69
- function ExpenseBoard() {
70
- const [statusFilter, setStatusFilter] = useState<Status | "all">("all");
71
-
72
- // Live, typed, reactive — a teammate's write re-renders this list.
73
- const { data: expenses, status, loadMore } = useList(app, "expenses", {
74
- filter: statusFilter === "all" ? undefined : { status: statusFilter },
75
- order: "desc",
76
- limit: 50,
77
- });
78
- const members = useMembers();
79
- const insert = useInsert(app, "expenses");
80
- const update = useUpdate(app, "expenses");
81
- const remove = useRemove(app, "expenses");
82
-
83
- const [title, setTitle] = useState("");
84
- const [amount, setAmount] = useState("");
85
- // "__none" sentinel: a controlled Radix Select must never flip to undefined
86
- // (it goes uncontrolled and keeps displaying the stale selection).
87
- const [assigneeId, setAssigneeId] = useState("__none");
88
- const [formError, setFormError] = useState<string | null>(null);
89
-
90
- async function addExpense() {
91
- setFormError(null);
92
- try {
93
- await insert({
94
- title,
95
- amount: Number(amount),
96
- ...(assigneeId !== "__none" ? { assigneeId } : {}),
97
- });
98
- setTitle("");
99
- setAmount("");
100
- setAssigneeId("__none");
101
- } catch (error) {
102
- setFormError(error instanceof Error ? error.message : String(error));
103
- }
104
- }
105
-
106
- const memberName = (userId?: string) =>
107
- members?.find((m) => m.userId === userId)?.name ?? "—";
108
-
18
+ // The starter ships empty on purpose: this page is what the person watching
19
+ // the preview sees while their agent builds the first version. Replace this
20
+ // whole route with the real app — don't build around it.
21
+ function BlankCanvas() {
109
22
  return (
110
- <main className="mx-auto flex max-w-4xl flex-col gap-6 p-6">
111
- <header className="flex items-center justify-between">
112
- <h1 className="text-2xl font-semibold">Expenses</h1>
113
- <Select
114
- value={statusFilter}
115
- onValueChange={(v) => setStatusFilter(v as Status | "all")}
116
- >
117
- <SelectTrigger className="w-40">
118
- <SelectValue placeholder="Filter status" />
119
- </SelectTrigger>
120
- <SelectContent>
121
- <SelectGroup>
122
- <SelectItem value="all">All statuses</SelectItem>
123
- <SelectItem value="draft">Draft</SelectItem>
124
- <SelectItem value="submitted">Submitted</SelectItem>
125
- <SelectItem value="approved">Approved</SelectItem>
126
- </SelectGroup>
127
- </SelectContent>
128
- </Select>
129
- </header>
130
-
131
- <Card>
132
- <CardHeader>
133
- <CardTitle>Add expense</CardTitle>
134
- </CardHeader>
135
- <CardContent>
136
- <FieldGroup>
137
- <div className="flex flex-wrap items-end gap-4">
138
- <Field className="min-w-48 flex-1">
139
- <FieldLabel htmlFor="title">Title</FieldLabel>
140
- <Input
141
- id="title"
142
- value={title}
143
- onChange={(e) => setTitle(e.target.value)}
144
- placeholder="Team lunch"
145
- />
146
- </Field>
147
- <Field className="w-32">
148
- <FieldLabel htmlFor="amount">Amount</FieldLabel>
149
- <Input
150
- id="amount"
151
- type="number"
152
- value={amount}
153
- onChange={(e) => setAmount(e.target.value)}
154
- placeholder="42.50"
155
- />
156
- </Field>
157
- <Field className="w-48">
158
- <FieldLabel>Assignee</FieldLabel>
159
- <Select value={assigneeId} onValueChange={setAssigneeId}>
160
- <SelectTrigger>
161
- <SelectValue placeholder="Unassigned" />
162
- </SelectTrigger>
163
- <SelectContent>
164
- <SelectGroup>
165
- <SelectItem value="__none">Unassigned</SelectItem>
166
- {(members ?? []).map((m) => (
167
- <SelectItem key={m.userId} value={m.userId}>
168
- {m.name}
169
- </SelectItem>
170
- ))}
171
- </SelectGroup>
172
- </SelectContent>
173
- </Select>
174
- </Field>
175
- <Button onClick={addExpense}>
176
- <PlusIcon data-icon="inline-start" />
177
- Add
178
- </Button>
179
- </div>
180
- {formError && <FieldError>{formError}</FieldError>}
181
- </FieldGroup>
182
- </CardContent>
183
- </Card>
184
-
185
- {status === "LoadingFirstPage" && expenses.length === 0 ? (
186
- <div className="flex justify-center p-12">
187
- <Spinner />
188
- </div>
189
- ) : expenses.length === 0 ? (
190
- <Empty>
191
- <EmptyHeader>
192
- <EmptyTitle>No expenses yet</EmptyTitle>
193
- <EmptyDescription>
194
- Add the first one above — it appears for every teammate in real
195
- time.
196
- </EmptyDescription>
197
- </EmptyHeader>
198
- </Empty>
199
- ) : (
200
- <Card>
201
- <CardContent>
202
- <Table>
203
- <TableHeader>
204
- <TableRow>
205
- <TableHead>Title</TableHead>
206
- <TableHead>Amount</TableHead>
207
- <TableHead>Status</TableHead>
208
- <TableHead>Assignee</TableHead>
209
- <TableHead className="w-40" />
210
- </TableRow>
211
- </TableHeader>
212
- <TableBody>
213
- {expenses.map((e) => (
214
- <TableRow key={e._id}>
215
- <TableCell className="font-medium">{e.title}</TableCell>
216
- <TableCell>${e.amount.toFixed(2)}</TableCell>
217
- <TableCell>
218
- <Badge variant={STATUS_VARIANT[e.status]}>
219
- {e.status}
220
- </Badge>
221
- </TableCell>
222
- <TableCell>{memberName(e.assigneeId)}</TableCell>
223
- <TableCell>
224
- <div className="flex justify-end gap-2">
225
- {NEXT_STATUS[e.status] && (
226
- <Button
227
- variant="outline"
228
- size="sm"
229
- onClick={() =>
230
- update(e._id, { status: NEXT_STATUS[e.status]! })
231
- }
232
- >
233
- <CheckIcon data-icon="inline-start" />
234
- {NEXT_STATUS[e.status]}
235
- </Button>
236
- )}
237
- <Button
238
- variant="ghost"
239
- size="sm"
240
- onClick={() => remove(e._id)}
241
- >
242
- <TrashIcon data-icon="inline-start" />
243
- Delete
244
- </Button>
245
- </div>
246
- </TableCell>
247
- </TableRow>
248
- ))}
249
- </TableBody>
250
- </Table>
251
- {status === "CanLoadMore" && (
252
- <div className="flex justify-center pt-4">
253
- <Button variant="ghost" onClick={() => loadMore()}>
254
- Load more
255
- </Button>
256
- </div>
257
- )}
258
- </CardContent>
259
- </Card>
260
- )}
23
+ <main className="grid min-h-dvh place-items-center p-6">
24
+ <Empty>
25
+ <EmptyHeader>
26
+ <EmptyMedia variant="icon">
27
+ <Sparkles />
28
+ </EmptyMedia>
29
+ <EmptyTitle>{app.name} is a blank canvas</EmptyTitle>
30
+ <EmptyDescription>
31
+ Your agent builds the first version right on this page — changes
32
+ appear live as it works.
33
+ </EmptyDescription>
34
+ </EmptyHeader>
35
+ </Empty>
261
36
  </main>
262
37
  );
263
38
  }