@agent-native/core 0.43.0 → 0.44.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.
- package/dist/chat-threads/store.d.ts.map +1 -1
- package/dist/chat-threads/store.js +71 -10
- package/dist/chat-threads/store.js.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.js +1 -1
- package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
- package/dist/cli/recap.d.ts.map +1 -1
- package/dist/cli/recap.js +2 -13
- package/dist/cli/recap.js.map +1 -1
- package/dist/cli/skills.d.ts +2 -2
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +13 -13
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +76 -18
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/blocks/index.d.ts +0 -2
- package/dist/client/blocks/index.d.ts.map +1 -1
- package/dist/client/blocks/index.js +0 -2
- package/dist/client/blocks/index.js.map +1 -1
- package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/AnnotatedCodeBlock.js +1 -1
- package/dist/client/blocks/library/AnnotatedCodeBlock.js.map +1 -1
- package/dist/client/blocks/library/ApiEndpointBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/ApiEndpointBlock.js +1 -1
- package/dist/client/blocks/library/ApiEndpointBlock.js.map +1 -1
- package/dist/client/blocks/library/DiffBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/DiffBlock.js +68 -24
- package/dist/client/blocks/library/DiffBlock.js.map +1 -1
- package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/FileTreeBlock.js +4 -0
- package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
- package/dist/client/blocks/library/annotation-rail.d.ts +0 -19
- package/dist/client/blocks/library/annotation-rail.d.ts.map +1 -1
- package/dist/client/blocks/library/annotation-rail.js +0 -19
- package/dist/client/blocks/library/annotation-rail.js.map +1 -1
- package/dist/client/blocks/library/code-tabs.js +1 -1
- package/dist/client/blocks/library/code-tabs.js.map +1 -1
- package/dist/client/blocks/library/server-specs.d.ts.map +1 -1
- package/dist/client/blocks/library/server-specs.js +0 -10
- package/dist/client/blocks/library/server-specs.js.map +1 -1
- package/dist/client/blocks/library/specs.d.ts.map +1 -1
- package/dist/client/blocks/library/specs.js +0 -2
- package/dist/client/blocks/library/specs.js.map +1 -1
- package/dist/client/blocks/library/wireframe.config.d.ts.map +1 -1
- package/dist/client/blocks/library/wireframe.config.js +19 -2
- package/dist/client/blocks/library/wireframe.config.js.map +1 -1
- package/dist/client/blocks/mdx.d.ts.map +1 -1
- package/dist/client/blocks/mdx.js +11 -0
- package/dist/client/blocks/mdx.js.map +1 -1
- package/dist/client/rich-markdown-editor/DragHandle.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/DragHandle.js +35 -72
- package/dist/client/rich-markdown-editor/DragHandle.js.map +1 -1
- package/dist/client/rich-markdown-editor/RegistryBlockNode.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/RegistryBlockNode.js +1 -1
- package/dist/client/rich-markdown-editor/RegistryBlockNode.js.map +1 -1
- package/dist/client/rich-markdown-editor/SharedRichEditor.d.ts +9 -1
- package/dist/client/rich-markdown-editor/SharedRichEditor.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/SharedRichEditor.js +3 -1
- package/dist/client/rich-markdown-editor/SharedRichEditor.js.map +1 -1
- package/dist/client/rich-markdown-editor/extensions.d.ts +13 -1
- package/dist/client/rich-markdown-editor/extensions.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/extensions.js +4 -2
- package/dist/client/rich-markdown-editor/extensions.js.map +1 -1
- package/dist/client/rich-markdown-editor/useCollabReconcile.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/useCollabReconcile.js +11 -1
- package/dist/client/rich-markdown-editor/useCollabReconcile.js.map +1 -1
- package/dist/server/poll.d.ts.map +1 -1
- package/dist/server/poll.js +30 -14
- package/dist/server/poll.js.map +1 -1
- package/dist/styles/blocks.css +10 -2
- package/dist/templates/default/.agents/skills/storing-data/SKILL.md +2 -0
- package/dist/templates/workspace-core/.agents/skills/performance/SKILL.md +141 -0
- package/dist/templates/workspace-core/.agents/skills/storing-data/SKILL.md +2 -0
- package/package.json +1 -1
- package/src/templates/default/.agents/skills/storing-data/SKILL.md +2 -0
- package/src/templates/workspace-core/.agents/skills/performance/SKILL.md +141 -0
- package/src/templates/workspace-core/.agents/skills/storing-data/SKILL.md +2 -0
- package/dist/client/blocks/library/decision.config.d.ts +0 -37
- package/dist/client/blocks/library/decision.config.d.ts.map +0 -1
- package/dist/client/blocks/library/decision.config.js +0 -32
- package/dist/client/blocks/library/decision.config.js.map +0 -1
- package/dist/client/blocks/library/decision.d.ts +0 -19
- package/dist/client/blocks/library/decision.d.ts.map +0 -1
- package/dist/client/blocks/library/decision.js +0 -119
- package/dist/client/blocks/library/decision.js.map +0 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: performance
|
|
3
|
+
description: >-
|
|
4
|
+
Keep apps and templates loading fast. Read when adding a data model, a
|
|
5
|
+
list/read action, a page or sidebar that loads data, or when something loads
|
|
6
|
+
slowly. Covers column projection, indexing hot-path queries, avoiding N+1 and
|
|
7
|
+
round-trip waterfalls, cheap polling, and not recomputing on every read.
|
|
8
|
+
metadata:
|
|
9
|
+
internal: true
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Performance — Keep Loads Fast
|
|
13
|
+
|
|
14
|
+
## Rule
|
|
15
|
+
|
|
16
|
+
Treat every list, every read, and every page load as a latency budget. Two
|
|
17
|
+
things dominate it: **how much data crosses the wire**, and **how many
|
|
18
|
+
round-trips and table scans it takes**. On a hosted/serverless SQL backend each
|
|
19
|
+
query is a network round-trip, and an unindexed filter scans the whole — often
|
|
20
|
+
shared and growing — table. So default to **projected columns**, **indexed
|
|
21
|
+
hot-path queries**, and **parallel/batched** fetches. These rules are
|
|
22
|
+
provider-agnostic: they hold on SQLite, Postgres, or any managed SQL backend.
|
|
23
|
+
|
|
24
|
+
This skill is about the data and load path. See the `storing-data` skill for the schema
|
|
25
|
+
and migration mechanics it references, and the `real-time-sync` skill for how updates
|
|
26
|
+
already reach the UI without polling.
|
|
27
|
+
|
|
28
|
+
## 1. Project columns — never `SELECT *` on a list
|
|
29
|
+
|
|
30
|
+
A list/index query should select only the columns the list actually renders.
|
|
31
|
+
|
|
32
|
+
- **Never return heavy columns in a list**: large JSON/text blobs such as
|
|
33
|
+
document bodies, rendered HTML, `config`/`layout`/`spec`/`data`/`tracks`,
|
|
34
|
+
tool results, or base64 attachments. Pulling them for every row is the single
|
|
35
|
+
most common cause of a slow list.
|
|
36
|
+
- Heavy/full columns belong on the **single-item GET/detail** path only.
|
|
37
|
+
- Need a preview from a big column? Select a **truncated substring at the DB**,
|
|
38
|
+
not the whole column — and it stays portable:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// Drizzle — project, and truncate the heavy column for the preview
|
|
42
|
+
const rows = await db
|
|
43
|
+
.select({
|
|
44
|
+
id: docs.id,
|
|
45
|
+
title: docs.title,
|
|
46
|
+
updatedAt: docs.updatedAt,
|
|
47
|
+
// substr/length work on both SQLite and Postgres
|
|
48
|
+
preview: sql<string>`substr(${docs.content}, 1, 400)`,
|
|
49
|
+
})
|
|
50
|
+
.from(docs)
|
|
51
|
+
.where(accessFilter(docs, docShares))
|
|
52
|
+
.orderBy(desc(docs.updatedAt));
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- After narrowing the projection, update the row mapper and its return type so a
|
|
56
|
+
dropped column is provably unused on the list path. If the list genuinely
|
|
57
|
+
renders a heavy column (a thumbnail, an inline preview the UI shows), keep it —
|
|
58
|
+
don't break behavior to chase a payload win.
|
|
59
|
+
|
|
60
|
+
## 2. Index the hot paths
|
|
61
|
+
|
|
62
|
+
Indexes are added through the **versioned migration array** in
|
|
63
|
+
`server/plugins/db.ts` as `CREATE INDEX IF NOT EXISTS …` — not through a
|
|
64
|
+
schema-level `index()` helper (the framework applies indexes via migrations; see
|
|
65
|
+
the `storing-data` skill). Add an index for any column a hot query **filters or sorts**
|
|
66
|
+
on. The recurring ones:
|
|
67
|
+
|
|
68
|
+
- **Ownable tables** → `(owner_email, org_id, <the list's ORDER BY column>)`.
|
|
69
|
+
Access scoping filters by owner/org and lists sort by `updated_at`/`created_at`.
|
|
70
|
+
- **Shares tables** (`{resource}_shares`) → `(resource_id, principal_type, principal_id)`.
|
|
71
|
+
Access checks run correlated `EXISTS` subqueries against these on every list.
|
|
72
|
+
- **Child / foreign-key columns** used to load children (e.g. `responses.form_id`,
|
|
73
|
+
`comments.parent_id`, an events log's `*_id`) → index the FK, plus its sort
|
|
74
|
+
column when the children are ordered. An unindexed FK means a full scan of the
|
|
75
|
+
child table on every parent open. **A foreign-key reference does not create an
|
|
76
|
+
index automatically** — add it explicitly.
|
|
77
|
+
- **Status-filtered lists** → match the real `WHERE`, e.g. `(owner_email, status)`
|
|
78
|
+
or `(status, <sort>)`.
|
|
79
|
+
|
|
80
|
+
Keep index DDL **dialect-agnostic and idempotent**:
|
|
81
|
+
|
|
82
|
+
```sql
|
|
83
|
+
CREATE INDEX IF NOT EXISTS forms_owner_org_updated_idx ON forms (owner_email, org_id, updated_at)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
No `DESC`, no partial `WHERE`, no provider-specific syntax — it then runs on
|
|
87
|
+
SQLite and Postgres alike, is safe to re-run, and applies on next startup.
|
|
88
|
+
Indexes mostly bite **as data grows** and on **unbounded child tables** (a
|
|
89
|
+
seq-scan of 10 rows is instant; of a shared, ever-growing log it is not), so
|
|
90
|
+
index the growing tables first.
|
|
91
|
+
|
|
92
|
+
## 3. Don't fan out queries — batch and parallelize
|
|
93
|
+
|
|
94
|
+
- **No N+1.** Never loop issuing one query per item. Load children for many
|
|
95
|
+
parents in one `inArray(child.parentId, ids)` query, then group in memory.
|
|
96
|
+
- **Count in SQL** (`count()`), never "select all rows then `.length`".
|
|
97
|
+
- **Parallelize independent queries** with `Promise.all` rather than sequential
|
|
98
|
+
`await`s — each `await` is another round-trip.
|
|
99
|
+
- Prefer **one composed endpoint** over several dependent calls.
|
|
100
|
+
|
|
101
|
+
## 4. Avoid client-side waterfalls
|
|
102
|
+
|
|
103
|
+
- Don't gate query B on query A's result unless B truly needs it. Fire
|
|
104
|
+
independent `useActionQuery` / `useQuery` hooks **in parallel**; never make the
|
|
105
|
+
loading skeleton wait on a serial chain.
|
|
106
|
+
- Load the visible page from one read where possible, and **lazy-load**
|
|
107
|
+
secondary / below-the-fold data after first paint.
|
|
108
|
+
|
|
109
|
+
## 5. Poll cheaply; compute once
|
|
110
|
+
|
|
111
|
+
- Updates already reach the UI through the `real-time-sync` skill (`useDbSync` / SSE).
|
|
112
|
+
Don't add an aggressive `refetchInterval` that re-runs a heavy list/read every
|
|
113
|
+
couple of seconds. If you must poll, use a **wide interval** and a **cheap**
|
|
114
|
+
endpoint.
|
|
115
|
+
- **Never do expensive per-request work on a read that runs on every load/poll**:
|
|
116
|
+
re-rendering HTML/markdown, pretty-printing, re-parsing / migrating /
|
|
117
|
+
normalizing / sanitizing stored JSON. Do that work at **write time** (store the
|
|
118
|
+
result) or compute it **lazily only for the caller that needs it**. Reads on
|
|
119
|
+
the hot path must be cheap.
|
|
120
|
+
- Data the UI doesn't display (export formats, alternate renderings) belongs in a
|
|
121
|
+
separate on-demand action, not baked into the hot read.
|
|
122
|
+
|
|
123
|
+
## 6. Big payloads and long lists
|
|
124
|
+
|
|
125
|
+
- **Paginate or window** unbounded lists (messages, responses, events, activity).
|
|
126
|
+
Don't load the entire history on open; load a recent window and fetch older on
|
|
127
|
+
demand.
|
|
128
|
+
- Don't store **unbounded blobs inline** in a row that a list/load pulls.
|
|
129
|
+
Reference large content separately so opening the parent stays cheap.
|
|
130
|
+
- **Virtualize** very long rendered lists on the client so off-screen rows aren't
|
|
131
|
+
parsed/rendered every update.
|
|
132
|
+
|
|
133
|
+
## Checklist — run before shipping a list/read or a new table
|
|
134
|
+
|
|
135
|
+
- [ ] List selects only displayed columns; heavy blobs excluded or `substr`-truncated.
|
|
136
|
+
- [ ] Every hot-path `WHERE` / `ORDER BY` column is indexed (owner/org/sort,
|
|
137
|
+
shares `resource_id`, child FKs, status filters) via a `db.ts` migration.
|
|
138
|
+
- [ ] No N+1; independent queries parallelized; counts via SQL `count()`.
|
|
139
|
+
- [ ] Client fires independent queries in parallel, not a waterfall.
|
|
140
|
+
- [ ] No heavy recompute on every read; no aggressive polling of heavy endpoints.
|
|
141
|
+
- [ ] Unbounded lists are paginated/windowed; large blobs aren't inlined on the hot path.
|
|
@@ -14,6 +14,8 @@ metadata:
|
|
|
14
14
|
|
|
15
15
|
All application data lives in **SQL** (SQLite locally, persistent database in production). The agent and UI share the same database. Do not store durable app data in the filesystem.
|
|
16
16
|
|
|
17
|
+
When you add a data model, a list, or a read path, also follow the `performance` skill: project only the columns a list renders, index the columns hot queries filter/sort on, and avoid query waterfalls — so apps stay fast as data grows.
|
|
18
|
+
|
|
17
19
|
## How It Works
|
|
18
20
|
|
|
19
21
|
Agent-native apps use Drizzle ORM over the configured SQL backend. Local development works out of the box with a SQLite file at `data/app.db`; production and shared preview deploys need a persistent `DATABASE_URL` because container/serverless filesystems can reset. The code should behave the same across backends, but the local SQLite file is not durable once deployed.
|
package/package.json
CHANGED
|
@@ -14,6 +14,8 @@ metadata:
|
|
|
14
14
|
|
|
15
15
|
All application data lives in **SQL** (SQLite locally, persistent database in production). The agent and UI share the same database. Do not store durable app data in the filesystem.
|
|
16
16
|
|
|
17
|
+
When you add a data model, a list, or a read path, also follow the `performance` skill: project only the columns a list renders, index the columns hot queries filter/sort on, and avoid query waterfalls — so apps stay fast as data grows.
|
|
18
|
+
|
|
17
19
|
## How It Works
|
|
18
20
|
|
|
19
21
|
Agent-native apps use Drizzle ORM over the configured SQL backend. Local development works out of the box with a SQLite file at `data/app.db`; production and shared preview deploys need a persistent `DATABASE_URL` because container/serverless filesystems can reset. The code should behave the same across backends, but the local SQLite file is not durable once deployed.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: performance
|
|
3
|
+
description: >-
|
|
4
|
+
Keep apps and templates loading fast. Read when adding a data model, a
|
|
5
|
+
list/read action, a page or sidebar that loads data, or when something loads
|
|
6
|
+
slowly. Covers column projection, indexing hot-path queries, avoiding N+1 and
|
|
7
|
+
round-trip waterfalls, cheap polling, and not recomputing on every read.
|
|
8
|
+
metadata:
|
|
9
|
+
internal: true
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Performance — Keep Loads Fast
|
|
13
|
+
|
|
14
|
+
## Rule
|
|
15
|
+
|
|
16
|
+
Treat every list, every read, and every page load as a latency budget. Two
|
|
17
|
+
things dominate it: **how much data crosses the wire**, and **how many
|
|
18
|
+
round-trips and table scans it takes**. On a hosted/serverless SQL backend each
|
|
19
|
+
query is a network round-trip, and an unindexed filter scans the whole — often
|
|
20
|
+
shared and growing — table. So default to **projected columns**, **indexed
|
|
21
|
+
hot-path queries**, and **parallel/batched** fetches. These rules are
|
|
22
|
+
provider-agnostic: they hold on SQLite, Postgres, or any managed SQL backend.
|
|
23
|
+
|
|
24
|
+
This skill is about the data and load path. See the `storing-data` skill for the schema
|
|
25
|
+
and migration mechanics it references, and the `real-time-sync` skill for how updates
|
|
26
|
+
already reach the UI without polling.
|
|
27
|
+
|
|
28
|
+
## 1. Project columns — never `SELECT *` on a list
|
|
29
|
+
|
|
30
|
+
A list/index query should select only the columns the list actually renders.
|
|
31
|
+
|
|
32
|
+
- **Never return heavy columns in a list**: large JSON/text blobs such as
|
|
33
|
+
document bodies, rendered HTML, `config`/`layout`/`spec`/`data`/`tracks`,
|
|
34
|
+
tool results, or base64 attachments. Pulling them for every row is the single
|
|
35
|
+
most common cause of a slow list.
|
|
36
|
+
- Heavy/full columns belong on the **single-item GET/detail** path only.
|
|
37
|
+
- Need a preview from a big column? Select a **truncated substring at the DB**,
|
|
38
|
+
not the whole column — and it stays portable:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// Drizzle — project, and truncate the heavy column for the preview
|
|
42
|
+
const rows = await db
|
|
43
|
+
.select({
|
|
44
|
+
id: docs.id,
|
|
45
|
+
title: docs.title,
|
|
46
|
+
updatedAt: docs.updatedAt,
|
|
47
|
+
// substr/length work on both SQLite and Postgres
|
|
48
|
+
preview: sql<string>`substr(${docs.content}, 1, 400)`,
|
|
49
|
+
})
|
|
50
|
+
.from(docs)
|
|
51
|
+
.where(accessFilter(docs, docShares))
|
|
52
|
+
.orderBy(desc(docs.updatedAt));
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- After narrowing the projection, update the row mapper and its return type so a
|
|
56
|
+
dropped column is provably unused on the list path. If the list genuinely
|
|
57
|
+
renders a heavy column (a thumbnail, an inline preview the UI shows), keep it —
|
|
58
|
+
don't break behavior to chase a payload win.
|
|
59
|
+
|
|
60
|
+
## 2. Index the hot paths
|
|
61
|
+
|
|
62
|
+
Indexes are added through the **versioned migration array** in
|
|
63
|
+
`server/plugins/db.ts` as `CREATE INDEX IF NOT EXISTS …` — not through a
|
|
64
|
+
schema-level `index()` helper (the framework applies indexes via migrations; see
|
|
65
|
+
the `storing-data` skill). Add an index for any column a hot query **filters or sorts**
|
|
66
|
+
on. The recurring ones:
|
|
67
|
+
|
|
68
|
+
- **Ownable tables** → `(owner_email, org_id, <the list's ORDER BY column>)`.
|
|
69
|
+
Access scoping filters by owner/org and lists sort by `updated_at`/`created_at`.
|
|
70
|
+
- **Shares tables** (`{resource}_shares`) → `(resource_id, principal_type, principal_id)`.
|
|
71
|
+
Access checks run correlated `EXISTS` subqueries against these on every list.
|
|
72
|
+
- **Child / foreign-key columns** used to load children (e.g. `responses.form_id`,
|
|
73
|
+
`comments.parent_id`, an events log's `*_id`) → index the FK, plus its sort
|
|
74
|
+
column when the children are ordered. An unindexed FK means a full scan of the
|
|
75
|
+
child table on every parent open. **A foreign-key reference does not create an
|
|
76
|
+
index automatically** — add it explicitly.
|
|
77
|
+
- **Status-filtered lists** → match the real `WHERE`, e.g. `(owner_email, status)`
|
|
78
|
+
or `(status, <sort>)`.
|
|
79
|
+
|
|
80
|
+
Keep index DDL **dialect-agnostic and idempotent**:
|
|
81
|
+
|
|
82
|
+
```sql
|
|
83
|
+
CREATE INDEX IF NOT EXISTS forms_owner_org_updated_idx ON forms (owner_email, org_id, updated_at)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
No `DESC`, no partial `WHERE`, no provider-specific syntax — it then runs on
|
|
87
|
+
SQLite and Postgres alike, is safe to re-run, and applies on next startup.
|
|
88
|
+
Indexes mostly bite **as data grows** and on **unbounded child tables** (a
|
|
89
|
+
seq-scan of 10 rows is instant; of a shared, ever-growing log it is not), so
|
|
90
|
+
index the growing tables first.
|
|
91
|
+
|
|
92
|
+
## 3. Don't fan out queries — batch and parallelize
|
|
93
|
+
|
|
94
|
+
- **No N+1.** Never loop issuing one query per item. Load children for many
|
|
95
|
+
parents in one `inArray(child.parentId, ids)` query, then group in memory.
|
|
96
|
+
- **Count in SQL** (`count()`), never "select all rows then `.length`".
|
|
97
|
+
- **Parallelize independent queries** with `Promise.all` rather than sequential
|
|
98
|
+
`await`s — each `await` is another round-trip.
|
|
99
|
+
- Prefer **one composed endpoint** over several dependent calls.
|
|
100
|
+
|
|
101
|
+
## 4. Avoid client-side waterfalls
|
|
102
|
+
|
|
103
|
+
- Don't gate query B on query A's result unless B truly needs it. Fire
|
|
104
|
+
independent `useActionQuery` / `useQuery` hooks **in parallel**; never make the
|
|
105
|
+
loading skeleton wait on a serial chain.
|
|
106
|
+
- Load the visible page from one read where possible, and **lazy-load**
|
|
107
|
+
secondary / below-the-fold data after first paint.
|
|
108
|
+
|
|
109
|
+
## 5. Poll cheaply; compute once
|
|
110
|
+
|
|
111
|
+
- Updates already reach the UI through the `real-time-sync` skill (`useDbSync` / SSE).
|
|
112
|
+
Don't add an aggressive `refetchInterval` that re-runs a heavy list/read every
|
|
113
|
+
couple of seconds. If you must poll, use a **wide interval** and a **cheap**
|
|
114
|
+
endpoint.
|
|
115
|
+
- **Never do expensive per-request work on a read that runs on every load/poll**:
|
|
116
|
+
re-rendering HTML/markdown, pretty-printing, re-parsing / migrating /
|
|
117
|
+
normalizing / sanitizing stored JSON. Do that work at **write time** (store the
|
|
118
|
+
result) or compute it **lazily only for the caller that needs it**. Reads on
|
|
119
|
+
the hot path must be cheap.
|
|
120
|
+
- Data the UI doesn't display (export formats, alternate renderings) belongs in a
|
|
121
|
+
separate on-demand action, not baked into the hot read.
|
|
122
|
+
|
|
123
|
+
## 6. Big payloads and long lists
|
|
124
|
+
|
|
125
|
+
- **Paginate or window** unbounded lists (messages, responses, events, activity).
|
|
126
|
+
Don't load the entire history on open; load a recent window and fetch older on
|
|
127
|
+
demand.
|
|
128
|
+
- Don't store **unbounded blobs inline** in a row that a list/load pulls.
|
|
129
|
+
Reference large content separately so opening the parent stays cheap.
|
|
130
|
+
- **Virtualize** very long rendered lists on the client so off-screen rows aren't
|
|
131
|
+
parsed/rendered every update.
|
|
132
|
+
|
|
133
|
+
## Checklist — run before shipping a list/read or a new table
|
|
134
|
+
|
|
135
|
+
- [ ] List selects only displayed columns; heavy blobs excluded or `substr`-truncated.
|
|
136
|
+
- [ ] Every hot-path `WHERE` / `ORDER BY` column is indexed (owner/org/sort,
|
|
137
|
+
shares `resource_id`, child FKs, status filters) via a `db.ts` migration.
|
|
138
|
+
- [ ] No N+1; independent queries parallelized; counts via SQL `count()`.
|
|
139
|
+
- [ ] Client fires independent queries in parallel, not a waterfall.
|
|
140
|
+
- [ ] No heavy recompute on every read; no aggressive polling of heavy endpoints.
|
|
141
|
+
- [ ] Unbounded lists are paginated/windowed; large blobs aren't inlined on the hot path.
|
|
@@ -14,6 +14,8 @@ metadata:
|
|
|
14
14
|
|
|
15
15
|
All application data lives in **SQL** (SQLite locally, persistent database in production). The agent and UI share the same database. Do not store durable app data in the filesystem.
|
|
16
16
|
|
|
17
|
+
When you add a data model, a list, or a read path, also follow the `performance` skill: project only the columns a list renders, index the columns hot queries filter/sort on, and avoid query waterfalls — so apps stay fast as data grows.
|
|
18
|
+
|
|
17
19
|
## How It Works
|
|
18
20
|
|
|
19
21
|
Agent-native apps use Drizzle ORM over the configured SQL backend. Local development works out of the box with a SQLite file at `data/app.db`; production and shared preview deploys need a persistent `DATABASE_URL` because container/serverless filesystems can reset. The code should behave the same across backends, but the local SQLite file is not durable once deployed.
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import type { BlockMdxConfig } from "../types.js";
|
|
3
|
-
/**
|
|
4
|
-
* Pure (React-free) part of the shared `decision` block: its data schema and MDX
|
|
5
|
-
* round-trip config. Lives in core so BOTH apps' server/shared registries and
|
|
6
|
-
* the client spec (`decision.tsx`) consume one definition. Keeping it React-free
|
|
7
|
-
* means importing it into a server module never pulls React into the Nitro/SSR
|
|
8
|
-
* bundle.
|
|
9
|
-
*
|
|
10
|
-
* The MDX `tag` + `question`/`options` attribute shape MUST match the legacy
|
|
11
|
-
* `<Decision question options />` encoding so stored `.mdx` round-trips
|
|
12
|
-
* byte-compatibly (the block originated in the plan template before moving here).
|
|
13
|
-
*/
|
|
14
|
-
export interface DecisionOption {
|
|
15
|
-
id: string;
|
|
16
|
-
label: string;
|
|
17
|
-
detail?: string;
|
|
18
|
-
/**
|
|
19
|
-
* Authored recommendation only. A reviewer's actual selection does NOT live
|
|
20
|
-
* here — responses belong in comments / events, never in the canonical
|
|
21
|
-
* document body.
|
|
22
|
-
*/
|
|
23
|
-
recommended?: boolean;
|
|
24
|
-
}
|
|
25
|
-
export interface DecisionData {
|
|
26
|
-
question: string;
|
|
27
|
-
options: DecisionOption[];
|
|
28
|
-
}
|
|
29
|
-
export declare const decisionSchema: z.ZodType<DecisionData>;
|
|
30
|
-
/**
|
|
31
|
-
* MDX config: `question` and `options` are both attributes — exactly the legacy
|
|
32
|
-
* `<Decision question options />` form. `toAttrs` writes them in their historical
|
|
33
|
-
* order; `fromAttrs` tolerates missing attributes with the same `?? "Decision"` /
|
|
34
|
-
* `?? []` defaults the plan template used.
|
|
35
|
-
*/
|
|
36
|
-
export declare const decisionMdx: BlockMdxConfig<DecisionData>;
|
|
37
|
-
//# sourceMappingURL=decision.config.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"decision.config.d.ts","sourceRoot":"","sources":["../../../../src/client/blocks/library/decision.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;;;;;;GAUG;AAEH,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,cAAc,EAAE,CAAC;CAC3B;AAID,eAAO,MAAM,cAAc,EAaV,CAAC,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;AAEzC;;;;;GAKG;AACH,eAAO,MAAM,WAAW,EAAE,cAAc,CAAC,YAAY,CAUpD,CAAC"}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
const decisionIdSchema = z.string().trim().min(1).max(120);
|
|
3
|
-
export const decisionSchema = z.object({
|
|
4
|
-
question: z.string().trim().min(1).max(500),
|
|
5
|
-
options: z
|
|
6
|
-
.array(z.object({
|
|
7
|
-
id: decisionIdSchema,
|
|
8
|
-
label: z.string().trim().min(1).max(200),
|
|
9
|
-
detail: z.string().trim().max(800).optional(),
|
|
10
|
-
recommended: z.boolean().optional(),
|
|
11
|
-
}))
|
|
12
|
-
.min(1)
|
|
13
|
-
.max(20),
|
|
14
|
-
});
|
|
15
|
-
/**
|
|
16
|
-
* MDX config: `question` and `options` are both attributes — exactly the legacy
|
|
17
|
-
* `<Decision question options />` form. `toAttrs` writes them in their historical
|
|
18
|
-
* order; `fromAttrs` tolerates missing attributes with the same `?? "Decision"` /
|
|
19
|
-
* `?? []` defaults the plan template used.
|
|
20
|
-
*/
|
|
21
|
-
export const decisionMdx = {
|
|
22
|
-
tag: "Decision",
|
|
23
|
-
toAttrs: (data) => ({
|
|
24
|
-
question: data.question,
|
|
25
|
-
options: data.options,
|
|
26
|
-
}),
|
|
27
|
-
fromAttrs: (attrs) => ({
|
|
28
|
-
question: attrs.string("question") ?? "Decision",
|
|
29
|
-
options: attrs.array("options") ?? [],
|
|
30
|
-
}),
|
|
31
|
-
};
|
|
32
|
-
//# sourceMappingURL=decision.config.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"decision.config.js","sourceRoot":"","sources":["../../../../src/client/blocks/library/decision.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAgCxB,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;AAE3D,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC;IACrC,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC;IAC3C,OAAO,EAAE,CAAC;SACP,KAAK,CACJ,CAAC,CAAC,MAAM,CAAC;QACP,EAAE,EAAE,gBAAgB;QACpB,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC;QACxC,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE;QAC7C,WAAW,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE;KACpC,CAAC,CACH;SACA,GAAG,CAAC,CAAC,CAAC;SACN,GAAG,CAAC,EAAE,CAAC;CACX,CAAuC,CAAC;AAEzC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,WAAW,GAAiC;IACvD,GAAG,EAAE,UAAU;IACf,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QAClB,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,OAAO,EAAE,IAAI,CAAC,OAAO;KACtB,CAAC;IACF,SAAS,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QACrB,QAAQ,EAAE,KAAK,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,UAAU;QAChD,OAAO,EAAE,KAAK,CAAC,KAAK,CAAiB,SAAS,CAAC,IAAI,EAAE;KACtD,CAAC;CACH,CAAC","sourcesContent":["import { z } from \"zod\";\nimport type { BlockMdxConfig } from \"../types.js\";\n\n/**\n * Pure (React-free) part of the shared `decision` block: its data schema and MDX\n * round-trip config. Lives in core so BOTH apps' server/shared registries and\n * the client spec (`decision.tsx`) consume one definition. Keeping it React-free\n * means importing it into a server module never pulls React into the Nitro/SSR\n * bundle.\n *\n * The MDX `tag` + `question`/`options` attribute shape MUST match the legacy\n * `<Decision question options />` encoding so stored `.mdx` round-trips\n * byte-compatibly (the block originated in the plan template before moving here).\n */\n\nexport interface DecisionOption {\n id: string;\n label: string;\n detail?: string;\n /**\n * Authored recommendation only. A reviewer's actual selection does NOT live\n * here — responses belong in comments / events, never in the canonical\n * document body.\n */\n recommended?: boolean;\n}\n\nexport interface DecisionData {\n question: string;\n options: DecisionOption[];\n}\n\nconst decisionIdSchema = z.string().trim().min(1).max(120);\n\nexport const decisionSchema = z.object({\n question: z.string().trim().min(1).max(500),\n options: z\n .array(\n z.object({\n id: decisionIdSchema,\n label: z.string().trim().min(1).max(200),\n detail: z.string().trim().max(800).optional(),\n recommended: z.boolean().optional(),\n }),\n )\n .min(1)\n .max(20),\n}) as unknown as z.ZodType<DecisionData>;\n\n/**\n * MDX config: `question` and `options` are both attributes — exactly the legacy\n * `<Decision question options />` form. `toAttrs` writes them in their historical\n * order; `fromAttrs` tolerates missing attributes with the same `?? \"Decision\"` /\n * `?? []` defaults the plan template used.\n */\nexport const decisionMdx: BlockMdxConfig<DecisionData> = {\n tag: \"Decision\",\n toAttrs: (data) => ({\n question: data.question,\n options: data.options,\n }),\n fromAttrs: (attrs) => ({\n question: attrs.string(\"question\") ?? \"Decision\",\n options: attrs.array<DecisionOption>(\"options\") ?? [],\n }),\n};\n"]}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import type { BlockReadProps, BlockEditProps } from "../types.js";
|
|
2
|
-
import { type DecisionData } from "./decision.config.js";
|
|
3
|
-
/**
|
|
4
|
-
* Standard `decision` block — a decision prompt with inline-editable option
|
|
5
|
-
* cards and one authored "recommended" choice. Lives in core so any app can
|
|
6
|
-
* register it (it originated in the plan template).
|
|
7
|
-
*
|
|
8
|
-
* The root `<section>` keeps the app-neutral `an-block` class (document-flow
|
|
9
|
-
* spacing hook) alongside the legacy `plan-block` class (styled by the plan
|
|
10
|
-
* template's own stylesheet), so plan renders as before and any other app gets
|
|
11
|
-
* theme-token styling. All inner color comes from shadcn theme tokens
|
|
12
|
-
* (`text-muted-foreground`, `text-foreground`, `bg-muted`, `bg-background`,
|
|
13
|
-
* `border-border`, `ring`), so it reads correctly in any template palette.
|
|
14
|
-
*/
|
|
15
|
-
export declare function DecisionBlock({ data, blockId, title, }: BlockReadProps<DecisionData>): import("react/jsx-runtime").JSX.Element;
|
|
16
|
-
export declare function DecisionBlockEdit({ data, onChange, editable, blockId, title, summary, ctx, }: BlockEditProps<DecisionData>): import("react/jsx-runtime").JSX.Element;
|
|
17
|
-
/** Full client spec for the shared `decision` block (schema + MDX + Read/Edit). */
|
|
18
|
-
export declare const decisionBlock: import("../types.js").BlockSpec<DecisionData>;
|
|
19
|
-
//# sourceMappingURL=decision.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"decision.d.ts","sourceRoot":"","sources":["../../../../src/client/blocks/library/decision.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClE,OAAO,EAGL,KAAK,YAAY,EAElB,MAAM,sBAAsB,CAAC;AAE9B;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,EAC5B,IAAI,EACJ,OAAO,EACP,KAAK,GACN,EAAE,cAAc,CAAC,YAAY,CAAC,2CAsC9B;AAaD,wBAAgB,iBAAiB,CAAC,EAChC,IAAI,EACJ,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,KAAK,EACL,OAAO,EACP,GAAG,GACJ,EAAE,cAAc,CAAC,YAAY,CAAC,2CAiI9B;AA+GD,mFAAmF;AACnF,eAAO,MAAM,aAAa,+CAgCxB,CAAC"}
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from "react";
|
|
3
|
-
import { IconCheck, IconPencil, IconPlus, IconTrash, } from "@tabler/icons-react";
|
|
4
|
-
import { cn } from "../../utils.js";
|
|
5
|
-
import { defineBlock } from "../types.js";
|
|
6
|
-
import { decisionMdx, decisionSchema, } from "./decision.config.js";
|
|
7
|
-
/**
|
|
8
|
-
* Standard `decision` block — a decision prompt with inline-editable option
|
|
9
|
-
* cards and one authored "recommended" choice. Lives in core so any app can
|
|
10
|
-
* register it (it originated in the plan template).
|
|
11
|
-
*
|
|
12
|
-
* The root `<section>` keeps the app-neutral `an-block` class (document-flow
|
|
13
|
-
* spacing hook) alongside the legacy `plan-block` class (styled by the plan
|
|
14
|
-
* template's own stylesheet), so plan renders as before and any other app gets
|
|
15
|
-
* theme-token styling. All inner color comes from shadcn theme tokens
|
|
16
|
-
* (`text-muted-foreground`, `text-foreground`, `bg-muted`, `bg-background`,
|
|
17
|
-
* `border-border`, `ring`), so it reads correctly in any template palette.
|
|
18
|
-
*/
|
|
19
|
-
export function DecisionBlock({ data, blockId, title, }) {
|
|
20
|
-
return (_jsxs("section", { className: "an-block plan-block", "data-block-id": blockId, children: [title && _jsx("div", { className: "an-block-label plan-block-label", children: title }), _jsx("p", { className: "mt-3 max-w-3xl text-lg leading-8 text-muted-foreground", children: data.question }), _jsx("div", { className: "mt-6 grid gap-3 md:grid-cols-2", children: data.options.map((option) => (_jsxs("article", { className: cn("rounded-xl border border-border bg-muted p-4", option.recommended
|
|
21
|
-
? "shadow-[inset_3px_0_0_hsl(var(--ring))]"
|
|
22
|
-
: "opacity-85"), children: [_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsx("h3", { className: "text-lg font-semibold tracking-tight text-foreground", children: option.label }), option.recommended && (_jsx("span", { className: "rounded-full border border-border px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground", children: "Recommended" }))] }), option.detail && (_jsx("p", { className: "mt-3 text-sm leading-6 text-muted-foreground", children: option.detail }))] }, option.id))) })] }));
|
|
23
|
-
}
|
|
24
|
-
const inlineInputClass = "w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring";
|
|
25
|
-
const inlineTextareaClass = "w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm leading-6 text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring";
|
|
26
|
-
const inlineLabelClass = "text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground";
|
|
27
|
-
function newLocalId(prefix) {
|
|
28
|
-
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
|
29
|
-
}
|
|
30
|
-
export function DecisionBlockEdit({ data, onChange, editable, blockId, title, summary, ctx, }) {
|
|
31
|
-
const updateOption = (optionId, patch) => onChange({
|
|
32
|
-
...data,
|
|
33
|
-
options: data.options.map((option) => option.id === optionId ? { ...option, ...patch } : option),
|
|
34
|
-
});
|
|
35
|
-
const removeOption = (optionId) => {
|
|
36
|
-
if (data.options.length <= 1)
|
|
37
|
-
return;
|
|
38
|
-
onChange({
|
|
39
|
-
...data,
|
|
40
|
-
options: data.options.filter((option) => option.id !== optionId),
|
|
41
|
-
});
|
|
42
|
-
};
|
|
43
|
-
const addOption = () => {
|
|
44
|
-
if (data.options.length >= 20)
|
|
45
|
-
return;
|
|
46
|
-
onChange({
|
|
47
|
-
...data,
|
|
48
|
-
options: [
|
|
49
|
-
...data.options,
|
|
50
|
-
{ id: newLocalId("option"), label: "New option" },
|
|
51
|
-
],
|
|
52
|
-
});
|
|
53
|
-
};
|
|
54
|
-
const settings = editable
|
|
55
|
-
? (ctx.renderEditSurface?.({
|
|
56
|
-
title: "Decision",
|
|
57
|
-
blockId,
|
|
58
|
-
blockType: "decision",
|
|
59
|
-
blockTitle: title,
|
|
60
|
-
blockSummary: summary,
|
|
61
|
-
blockData: data,
|
|
62
|
-
trigger: (_jsx("button", { type: "button", "data-plan-interactive": true, "aria-label": "Edit decision options", className: "flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground", children: _jsx(IconPencil, { className: "size-4" }) })),
|
|
63
|
-
children: (_jsx(DecisionSettings, { options: data.options, onToggleRecommended: (option) => updateOption(option.id, { recommended: !option.recommended }), onRemove: removeOption, onAdd: addOption })),
|
|
64
|
-
}) ?? (_jsx(DecisionInlineSettings, { options: data.options, onToggleRecommended: (option) => updateOption(option.id, { recommended: !option.recommended }), onRemove: removeOption, onAdd: addOption })))
|
|
65
|
-
: null;
|
|
66
|
-
return (_jsxs("div", { className: "grid gap-5", "data-plan-interactive": true, children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsxs("label", { className: "grid min-w-0 flex-1 gap-1.5", children: [_jsx("span", { className: inlineLabelClass, children: "Question" }), _jsx("textarea", { className: inlineTextareaClass, rows: 2, value: data.question, disabled: !editable, onChange: (event) => onChange({ ...data, question: event.target.value }) })] }), settings] }), _jsx("div", { className: "grid gap-3", children: data.options.map((option) => (_jsxs("article", { className: cn("rounded-lg border border-border bg-muted p-4", option.recommended &&
|
|
67
|
-
"border-ring/60 shadow-[inset_3px_0_0_hsl(var(--ring))]"), children: [_jsx("div", { className: "grid gap-3", children: _jsxs("label", { className: "grid gap-1.5", children: [_jsx("span", { className: inlineLabelClass, children: "Option" }), _jsx("input", { className: inlineInputClass, value: option.label, disabled: !editable, onChange: (event) => updateOption(option.id, { label: event.target.value }) })] }) }), _jsxs("label", { className: "mt-3 grid gap-1.5", children: [_jsx("span", { className: inlineLabelClass, children: "Detail" }), _jsx("textarea", { className: inlineTextareaClass, rows: 2, value: option.detail ?? "", disabled: !editable, onChange: (event) => updateOption(option.id, {
|
|
68
|
-
detail: event.target.value || undefined,
|
|
69
|
-
}) })] })] }, option.id))) })] }));
|
|
70
|
-
}
|
|
71
|
-
/** Option-management controls rendered inside the host's edit surface popover. */
|
|
72
|
-
function DecisionSettings({ options, onToggleRecommended, onRemove, onAdd, }) {
|
|
73
|
-
return (_jsxs("div", { className: "grid gap-3", children: [_jsx("div", { className: "text-sm font-semibold text-foreground", children: "Decision settings" }), _jsx("div", { className: "grid gap-2", children: options.map((option, index) => (_jsxs("div", { className: "grid gap-2 rounded-md border border-border bg-muted/20 p-2", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "min-w-0 truncate text-xs font-medium text-foreground", children: option.label.trim() || `Option ${index + 1}` }), option.recommended && (_jsx("span", { className: "shrink-0 rounded-full border border-border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground", children: "Recommended" }))] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("button", { type: "button", "data-plan-interactive": true, onClick: () => onToggleRecommended(option), className: cn("inline-flex h-8 flex-1 items-center justify-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors", option.recommended
|
|
74
|
-
? "bg-background text-foreground shadow-sm"
|
|
75
|
-
: "text-muted-foreground hover:bg-background/70 hover:text-foreground"), children: [option.recommended && _jsx(IconCheck, { className: "size-3.5" }), option.recommended ? "Recommended" : "Mark recommended"] }), _jsx("button", { type: "button", "data-plan-interactive": true, "aria-label": `Delete ${option.label || `option ${index + 1}`}`, disabled: options.length <= 1, onClick: () => onRemove(option.id), className: "inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-border text-destructive transition-colors hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-50", children: _jsx(IconTrash, { className: "size-3.5" }) })] })] }, option.id))) }), _jsxs("button", { type: "button", "data-plan-interactive": true, disabled: options.length >= 20, onClick: onAdd, className: "inline-flex h-8 items-center justify-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50", children: [_jsx(IconPlus, { className: "size-3.5" }), "Add option"] })] }));
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Fallback for hosts that do not provide `ctx.renderEditSurface`: a plain
|
|
79
|
-
* disclosure button that reveals the same settings inline (no overlay primitive,
|
|
80
|
-
* so core stays shadcn-free).
|
|
81
|
-
*/
|
|
82
|
-
function DecisionInlineSettings(props) {
|
|
83
|
-
const [open, setOpen] = useState(false);
|
|
84
|
-
return (_jsxs("div", { className: "grid gap-2", children: [_jsx("button", { type: "button", "data-plan-interactive": true, "aria-label": "Edit decision options", "aria-expanded": open, onClick: () => setOpen((value) => !value), className: "flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground", children: _jsx(IconPencil, { className: "size-4" }) }), open && (_jsx("div", { className: "w-80 rounded-md border border-border bg-background p-3 shadow-sm", children: _jsx(DecisionSettings, { ...props }) }))] }));
|
|
85
|
-
}
|
|
86
|
-
/** Full client spec for the shared `decision` block (schema + MDX + Read/Edit). */
|
|
87
|
-
export const decisionBlock = defineBlock({
|
|
88
|
-
type: "decision",
|
|
89
|
-
schema: decisionSchema,
|
|
90
|
-
mdx: decisionMdx,
|
|
91
|
-
Read: DecisionBlock,
|
|
92
|
-
Edit: DecisionBlockEdit,
|
|
93
|
-
placement: ["block"],
|
|
94
|
-
// `panel`: the document shows the clean read view (question + option cards with
|
|
95
|
-
// the recommended pick highlighted), and the corner pencil opens the editor in
|
|
96
|
-
// a popover. NOT `inline` — an inline schema-editing form (question + per-option
|
|
97
|
-
// textareas) rendered straight into the doc reads as a confusing data-entry wall
|
|
98
|
-
// rather than a decision. Mirrors how `question-form` / `visual-questions` edit.
|
|
99
|
-
editSurface: "panel",
|
|
100
|
-
label: "Decision",
|
|
101
|
-
description: "A decision prompt with option cards and an authored recommended choice. Shows a clean read view in the document; edit the question and options from the corner pencil.",
|
|
102
|
-
empty: () => ({
|
|
103
|
-
question: "Which implementation direction should we take?",
|
|
104
|
-
options: [
|
|
105
|
-
{
|
|
106
|
-
id: "recommended",
|
|
107
|
-
label: "Recommended path",
|
|
108
|
-
detail: "Smallest useful slice with clear rollback.",
|
|
109
|
-
recommended: true,
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
id: "alternative",
|
|
113
|
-
label: "Alternative",
|
|
114
|
-
detail: "Broader pass that touches more surfaces.",
|
|
115
|
-
},
|
|
116
|
-
],
|
|
117
|
-
}),
|
|
118
|
-
});
|
|
119
|
-
//# sourceMappingURL=decision.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"decision.js","sourceRoot":"","sources":["../../../../src/client/blocks/library/decision.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useState } from \"react\";\nimport {\n IconCheck,\n IconPencil,\n IconPlus,\n IconTrash,\n} from \"@tabler/icons-react\";\nimport { cn } from \"../../utils.js\";\nimport { defineBlock } from \"../types.js\";\nimport type { BlockReadProps, BlockEditProps } from \"../types.js\";\nimport {\n decisionMdx,\n decisionSchema,\n type DecisionData,\n type DecisionOption,\n} from \"./decision.config.js\";\n\n/**\n * Standard `decision` block — a decision prompt with inline-editable option\n * cards and one authored \"recommended\" choice. Lives in core so any app can\n * register it (it originated in the plan template).\n *\n * The root `<section>` keeps the app-neutral `an-block` class (document-flow\n * spacing hook) alongside the legacy `plan-block` class (styled by the plan\n * template's own stylesheet), so plan renders as before and any other app gets\n * theme-token styling. All inner color comes from shadcn theme tokens\n * (`text-muted-foreground`, `text-foreground`, `bg-muted`, `bg-background`,\n * `border-border`, `ring`), so it reads correctly in any template palette.\n */\nexport function DecisionBlock({\n data,\n blockId,\n title,\n}: BlockReadProps<DecisionData>) {\n return (\n <section className=\"an-block plan-block\" data-block-id={blockId}>\n {title && <div className=\"an-block-label plan-block-label\">{title}</div>}\n <p className=\"mt-3 max-w-3xl text-lg leading-8 text-muted-foreground\">\n {data.question}\n </p>\n <div className=\"mt-6 grid gap-3 md:grid-cols-2\">\n {data.options.map((option) => (\n <article\n key={option.id}\n className={cn(\n \"rounded-xl border border-border bg-muted p-4\",\n option.recommended\n ? \"shadow-[inset_3px_0_0_hsl(var(--ring))]\"\n : \"opacity-85\",\n )}\n >\n <div className=\"flex items-start justify-between gap-3\">\n <h3 className=\"text-lg font-semibold tracking-tight text-foreground\">\n {option.label}\n </h3>\n {option.recommended && (\n <span className=\"rounded-full border border-border px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground\">\n Recommended\n </span>\n )}\n </div>\n {option.detail && (\n <p className=\"mt-3 text-sm leading-6 text-muted-foreground\">\n {option.detail}\n </p>\n )}\n </article>\n ))}\n </div>\n </section>\n );\n}\n\nconst inlineInputClass =\n \"w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring\";\nconst inlineTextareaClass =\n \"w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm leading-6 text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring\";\nconst inlineLabelClass =\n \"text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground\";\n\nfunction newLocalId(prefix: string): string {\n return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nexport function DecisionBlockEdit({\n data,\n onChange,\n editable,\n blockId,\n title,\n summary,\n ctx,\n}: BlockEditProps<DecisionData>) {\n const updateOption = (optionId: string, patch: Partial<DecisionOption>) =>\n onChange({\n ...data,\n options: data.options.map((option) =>\n option.id === optionId ? { ...option, ...patch } : option,\n ),\n });\n\n const removeOption = (optionId: string) => {\n if (data.options.length <= 1) return;\n onChange({\n ...data,\n options: data.options.filter((option) => option.id !== optionId),\n });\n };\n\n const addOption = () => {\n if (data.options.length >= 20) return;\n onChange({\n ...data,\n options: [\n ...data.options,\n { id: newLocalId(\"option\"), label: \"New option\" },\n ],\n });\n };\n\n const settings = editable\n ? (ctx.renderEditSurface?.({\n title: \"Decision\",\n blockId,\n blockType: \"decision\",\n blockTitle: title,\n blockSummary: summary,\n blockData: data,\n trigger: (\n <button\n type=\"button\"\n data-plan-interactive\n aria-label=\"Edit decision options\"\n className=\"flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground\"\n >\n <IconPencil className=\"size-4\" />\n </button>\n ),\n children: (\n <DecisionSettings\n options={data.options}\n onToggleRecommended={(option) =>\n updateOption(option.id, { recommended: !option.recommended })\n }\n onRemove={removeOption}\n onAdd={addOption}\n />\n ),\n }) ?? (\n // No panel surface provided by the host: fall back to a plain inline\n // settings card so option management still works.\n <DecisionInlineSettings\n options={data.options}\n onToggleRecommended={(option) =>\n updateOption(option.id, { recommended: !option.recommended })\n }\n onRemove={removeOption}\n onAdd={addOption}\n />\n ))\n : null;\n\n return (\n <div className=\"grid gap-5\" data-plan-interactive>\n <div className=\"flex items-start gap-3\">\n <label className=\"grid min-w-0 flex-1 gap-1.5\">\n <span className={inlineLabelClass}>Question</span>\n <textarea\n className={inlineTextareaClass}\n rows={2}\n value={data.question}\n disabled={!editable}\n onChange={(event) =>\n onChange({ ...data, question: event.target.value })\n }\n />\n </label>\n {settings}\n </div>\n <div className=\"grid gap-3\">\n {data.options.map((option) => (\n <article\n key={option.id}\n className={cn(\n \"rounded-lg border border-border bg-muted p-4\",\n option.recommended &&\n \"border-ring/60 shadow-[inset_3px_0_0_hsl(var(--ring))]\",\n )}\n >\n <div className=\"grid gap-3\">\n <label className=\"grid gap-1.5\">\n <span className={inlineLabelClass}>Option</span>\n <input\n className={inlineInputClass}\n value={option.label}\n disabled={!editable}\n onChange={(event) =>\n updateOption(option.id, { label: event.target.value })\n }\n />\n </label>\n </div>\n <label className=\"mt-3 grid gap-1.5\">\n <span className={inlineLabelClass}>Detail</span>\n <textarea\n className={inlineTextareaClass}\n rows={2}\n value={option.detail ?? \"\"}\n disabled={!editable}\n onChange={(event) =>\n updateOption(option.id, {\n detail: event.target.value || undefined,\n })\n }\n />\n </label>\n </article>\n ))}\n </div>\n </div>\n );\n}\n\n/** Option-management controls rendered inside the host's edit surface popover. */\nfunction DecisionSettings({\n options,\n onToggleRecommended,\n onRemove,\n onAdd,\n}: {\n options: DecisionOption[];\n onToggleRecommended: (option: DecisionOption) => void;\n onRemove: (optionId: string) => void;\n onAdd: () => void;\n}) {\n return (\n <div className=\"grid gap-3\">\n <div className=\"text-sm font-semibold text-foreground\">\n Decision settings\n </div>\n <div className=\"grid gap-2\">\n {options.map((option, index) => (\n <div\n key={option.id}\n className=\"grid gap-2 rounded-md border border-border bg-muted/20 p-2\"\n >\n <div className=\"flex items-center justify-between gap-2\">\n <span className=\"min-w-0 truncate text-xs font-medium text-foreground\">\n {option.label.trim() || `Option ${index + 1}`}\n </span>\n {option.recommended && (\n <span className=\"shrink-0 rounded-full border border-border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground\">\n Recommended\n </span>\n )}\n </div>\n <div className=\"flex items-center gap-2\">\n <button\n type=\"button\"\n data-plan-interactive\n onClick={() => onToggleRecommended(option)}\n className={cn(\n \"inline-flex h-8 flex-1 items-center justify-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors\",\n option.recommended\n ? \"bg-background text-foreground shadow-sm\"\n : \"text-muted-foreground hover:bg-background/70 hover:text-foreground\",\n )}\n >\n {option.recommended && <IconCheck className=\"size-3.5\" />}\n {option.recommended ? \"Recommended\" : \"Mark recommended\"}\n </button>\n <button\n type=\"button\"\n data-plan-interactive\n aria-label={`Delete ${option.label || `option ${index + 1}`}`}\n disabled={options.length <= 1}\n onClick={() => onRemove(option.id)}\n className=\"inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-border text-destructive transition-colors hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <IconTrash className=\"size-3.5\" />\n </button>\n </div>\n </div>\n ))}\n </div>\n <button\n type=\"button\"\n data-plan-interactive\n disabled={options.length >= 20}\n onClick={onAdd}\n className=\"inline-flex h-8 items-center justify-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <IconPlus className=\"size-3.5\" />\n Add option\n </button>\n </div>\n );\n}\n\n/**\n * Fallback for hosts that do not provide `ctx.renderEditSurface`: a plain\n * disclosure button that reveals the same settings inline (no overlay primitive,\n * so core stays shadcn-free).\n */\nfunction DecisionInlineSettings(props: {\n options: DecisionOption[];\n onToggleRecommended: (option: DecisionOption) => void;\n onRemove: (optionId: string) => void;\n onAdd: () => void;\n}) {\n const [open, setOpen] = useState(false);\n return (\n <div className=\"grid gap-2\">\n <button\n type=\"button\"\n data-plan-interactive\n aria-label=\"Edit decision options\"\n aria-expanded={open}\n onClick={() => setOpen((value) => !value)}\n className=\"flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground\"\n >\n <IconPencil className=\"size-4\" />\n </button>\n {open && (\n <div className=\"w-80 rounded-md border border-border bg-background p-3 shadow-sm\">\n <DecisionSettings {...props} />\n </div>\n )}\n </div>\n );\n}\n\n/** Full client spec for the shared `decision` block (schema + MDX + Read/Edit). */\nexport const decisionBlock = defineBlock<DecisionData>({\n type: \"decision\",\n schema: decisionSchema,\n mdx: decisionMdx,\n Read: DecisionBlock,\n Edit: DecisionBlockEdit,\n placement: [\"block\"],\n // `panel`: the document shows the clean read view (question + option cards with\n // the recommended pick highlighted), and the corner pencil opens the editor in\n // a popover. NOT `inline` — an inline schema-editing form (question + per-option\n // textareas) rendered straight into the doc reads as a confusing data-entry wall\n // rather than a decision. Mirrors how `question-form` / `visual-questions` edit.\n editSurface: \"panel\",\n label: \"Decision\",\n description:\n \"A decision prompt with option cards and an authored recommended choice. Shows a clean read view in the document; edit the question and options from the corner pencil.\",\n empty: () => ({\n question: \"Which implementation direction should we take?\",\n options: [\n {\n id: \"recommended\",\n label: \"Recommended path\",\n detail: \"Smallest useful slice with clear rollback.\",\n recommended: true,\n },\n {\n id: \"alternative\",\n label: \"Alternative\",\n detail: \"Broader pass that touches more surfaces.\",\n },\n ],\n }),\n});\n"]}
|