@montytools/cli 0.2.10 → 0.4.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@montytools/cli",
3
- "version": "0.2.10",
3
+ "version": "0.4.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/TomasMonty/monty-v2.git",
@@ -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
@@ -12,29 +12,42 @@ platform's job. The complete contract lives in the app's own `AGENTS.md`
12
12
  the territory.
13
13
 
14
14
  Every app has two states: **Live** (published — what members and visitors
15
- see) and **Studio** (development — your running `monty dev` session on
16
- `#dev`-sandboxed data). Those are the only names for them; "dev"/"prod"
17
- mean something else on this platform.
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
18
 
19
19
  ## Rules
20
20
 
21
- 1. **Folders are managed.** Apps live in `~/Monty/<slug>`. `monty current`
22
- tells you where you are; `cd "$(monty select <slug>)"` jumps to an app;
23
- `monty apps` lists local ones. Never mkdir app folders by hand.
24
- 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>` →
25
28
  (if the prompt includes a `build id`, pass it: `--build <id>` — the
26
29
  workspace's New app screen tracks your progress live) →
27
30
  `monty install` → edit `monty.config.ts` (zod tables) + `src/routes/` →
28
- verify with `monty dev` — the app's Studio state (auto-port, already
29
- authenticated, sandboxed data). **You are done when `monty dev` prints
30
- the workspace Studio URL and the app works leave `monty dev`
31
- running.** Taking the app Live is the OWNER'S click (Publish in the
32
- workspace menu bar); **never run `monty deploy` yourself** unless the
33
- user explicitly asks for a direct Live 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.
34
45
  3. **Everything through the CLI.** `monty install`, `monty build`,
35
46
  `monty typecheck`, `monty dev`, `monty deploy` — never run vite, tsc,
36
47
  pnpm, or npm scripts directly. `monty dev` auto-picks a free port and
37
- 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.
38
51
  4. **One import surface:** `@montytools/sdk` (`defineApp`, zod) and
39
52
  `@montytools/sdk/react` (hooks: `useList`, `useInsert`, …). Never import
40
53
  Clerk or Convex directly; never fetch external APIs from app code — the
@@ -46,7 +59,7 @@ mean something else on this platform.
46
59
  7. **Errors are instructions.** Every failure prints
47
60
  `[MontyError CODE] Fix: …` — do exactly what the Fix says; don't guess.
48
61
  Typecheck failures block deploy by design.
49
- 8. **Verify before publish.** `monty dev` writes to the `#dev` Studio
62
+ 8. **Verify before publish.** The dev shell writes to the `#dev` Studio
50
63
  sandbox — Live team records are never touched, so exercise the app for
51
64
  real.
52
65
 
@@ -55,10 +68,11 @@ mean something else on this platform.
55
68
  | command | purpose |
56
69
  |---|---|
57
70
  | `monty login` | browser sign-in (loopback authorize), once per machine |
58
- | `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) |
59
72
  | `monty current` / `select` / `apps` | where am I / jump to app / list local |
60
73
  | `monty install` / `build` / `typecheck` | full lifecycle via the CLI — no raw pnpm/vite/tsc |
61
- | `monty dev` | run the app in Studio (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 |
62
76
  | `monty add <name…>` | install curated shadcn components |
63
77
  | `monty deploy` | build + typecheck + upload straight to Live (owner escape hatch) |
64
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
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ dist/
3
+ .monty/
4
+ .env.local
5
+ .env
@@ -3,10 +3,21 @@
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
- <div id="root"></div>
9
+ <div id="root">
10
+ <!-- Boot splash: the first paint, shown while the JS loads. React
11
+ replaces it with the SDK's identical MontySplash, so a cold open
12
+ is one continuous visual. If you theme the app dark-only, change
13
+ the background below to match. -->
14
+ <style>
15
+ @keyframes monty-pulse { 0%, 100% { opacity: 1 } 50% { opacity: .25 } }
16
+ </style>
17
+ <div style="display: grid; place-items: center; min-height: 100vh; background: oklch(1 0 0)">
18
+ <div style="width: 10px; height: 10px; border-radius: 999px; background: #999; animation: monty-pulse 1.2s ease-in-out infinite"></div>
19
+ </div>
20
+ </div>
10
21
  <script type="module" src="/src/main.tsx"></script>
11
22
  </body>
12
23
  </html>
@@ -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.3",
13
+ "@montytools/sdk": "^0.1.5",
14
14
  "@tanstack/react-router": "1.170.17",
15
15
  "class-variance-authority": "^0.7.1",
16
16
  "clsx": "^2.1.1",
@@ -1,13 +1,15 @@
1
1
  import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import { RouterProvider, createRouter } from "@tanstack/react-router";
4
- import { MontyProvider } from "@montytools/sdk/react";
4
+ import { MontyProvider, MontySplash } from "@montytools/sdk/react";
5
5
 
6
6
  import "./index.css";
7
7
  import { app } from "../monty.config";
8
8
  import { routeTree } from "./routeTree.gen";
9
9
 
10
- const router = createRouter({ routeTree });
10
+ // defaultPendingComponent: lazy route chunks show the same splash the rest
11
+ // of the boot uses instead of flashing blank.
12
+ const router = createRouter({ routeTree, defaultPendingComponent: MontySplash });
11
13
 
12
14
  declare module "@tanstack/react-router" {
13
15
  interface Register {
@@ -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
  }