@agent-native/core 0.106.0 → 0.106.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.
Files changed (67) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/application-state/index.ts +2 -0
  5. package/corpus/core/src/application-state/script-helpers.ts +12 -0
  6. package/corpus/core/src/application-state/store.ts +38 -0
  7. package/corpus/core/src/client/review/ReviewCommentComposer.tsx +10 -3
  8. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +22 -0
  9. package/corpus/templates/design/AGENTS.md +16 -0
  10. package/corpus/templates/design/actions/cancel-node-rewrite-request.ts +54 -0
  11. package/corpus/templates/design/actions/propose-node-rewrite.ts +290 -0
  12. package/corpus/templates/design/actions/resolve-node-rewrite.ts +257 -0
  13. package/corpus/templates/design/actions/view-screen.ts +98 -2
  14. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +71 -1
  15. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +19 -0
  16. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +33 -0
  17. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +194 -0
  18. package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +21 -4
  19. package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +18 -0
  20. package/corpus/templates/design/app/components/design/multi-screen/types.ts +4 -0
  21. package/corpus/templates/design/app/components/visual-editor/NodeRewriteProposal.tsx +638 -0
  22. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +535 -54
  23. package/corpus/templates/design/app/components/visual-editor/index.ts +5 -1
  24. package/corpus/templates/design/app/components/visual-editor/review-canvas-state.ts +21 -3
  25. package/corpus/templates/design/app/i18n/ar-SA.ts +35 -0
  26. package/corpus/templates/design/app/i18n/de-DE.ts +35 -0
  27. package/corpus/templates/design/app/i18n/es-ES.ts +35 -0
  28. package/corpus/templates/design/app/i18n/fr-FR.ts +35 -0
  29. package/corpus/templates/design/app/i18n/hi-IN.ts +35 -0
  30. package/corpus/templates/design/app/i18n/ja-JP.ts +35 -0
  31. package/corpus/templates/design/app/i18n/ko-KR.ts +35 -0
  32. package/corpus/templates/design/app/i18n/pt-BR.ts +35 -0
  33. package/corpus/templates/design/app/i18n/zh-CN.ts +35 -0
  34. package/corpus/templates/design/app/i18n/zh-TW.ts +32 -0
  35. package/corpus/templates/design/app/i18n-data.ts +32 -0
  36. package/corpus/templates/design/app/lib/node-reprompt.ts +110 -0
  37. package/corpus/templates/design/changelog/2026-07-16-design-editing-and-review-are-now-more-predictable.md +6 -0
  38. package/corpus/templates/design/server/lib/reprompt-action-guard.ts +107 -0
  39. package/corpus/templates/design/server/plugins/agent-chat.ts +9 -1
  40. package/corpus/templates/design/shared/code-layer.ts +15 -0
  41. package/corpus/templates/design/shared/node-rewrite.ts +213 -0
  42. package/corpus/templates/design/shared/review-anchor.ts +26 -3
  43. package/dist/application-state/index.d.ts +2 -2
  44. package/dist/application-state/index.d.ts.map +1 -1
  45. package/dist/application-state/index.js +2 -2
  46. package/dist/application-state/index.js.map +1 -1
  47. package/dist/application-state/script-helpers.d.ts +1 -0
  48. package/dist/application-state/script-helpers.d.ts.map +1 -1
  49. package/dist/application-state/script-helpers.js +7 -1
  50. package/dist/application-state/script-helpers.js.map +1 -1
  51. package/dist/application-state/store.d.ts +1 -0
  52. package/dist/application-state/store.d.ts.map +1 -1
  53. package/dist/application-state/store.js +28 -0
  54. package/dist/application-state/store.js.map +1 -1
  55. package/dist/client/review/ReviewCommentComposer.d.ts +4 -1
  56. package/dist/client/review/ReviewCommentComposer.d.ts.map +1 -1
  57. package/dist/client/review/ReviewCommentComposer.js +3 -3
  58. package/dist/client/review/ReviewCommentComposer.js.map +1 -1
  59. package/dist/collab/awareness.d.ts +2 -2
  60. package/dist/collab/awareness.d.ts.map +1 -1
  61. package/dist/collab/struct-routes.d.ts +1 -1
  62. package/dist/notifications/routes.d.ts +2 -2
  63. package/dist/observability/routes.d.ts +5 -5
  64. package/dist/resources/handlers.d.ts +1 -1
  65. package/dist/secrets/routes.d.ts +9 -9
  66. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  67. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2283
31
- - template files: 6054
31
+ - template files: 6062
@@ -1,5 +1,11 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.106.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 0d8118e: Allow review comment composers to render a custom agent action in the standard action row, choose the Enter-key submit target, and atomically compare-and-set application state for race-safe UI workflows.
8
+
3
9
  ## 0.106.0
4
10
 
5
11
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.106.0",
3
+ "version": "0.106.1",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -4,6 +4,7 @@ export {
4
4
  appStateGetMany,
5
5
  appStatePut,
6
6
  appStateDelete,
7
+ appStateCompareAndSet,
7
8
  appStateList,
8
9
  appStateDeleteByPrefix,
9
10
  } from "./store.js";
@@ -33,6 +34,7 @@ export {
33
34
  readAppState,
34
35
  writeAppState,
35
36
  deleteAppState,
37
+ compareAndSetAppState,
36
38
  listAppState,
37
39
  deleteAppStateByPrefix,
38
40
  readAppStateForCurrentTab,
@@ -17,6 +17,7 @@ import {
17
17
  appStateGet,
18
18
  appStatePut,
19
19
  appStateDelete,
20
+ appStateCompareAndSet,
20
21
  appStateList,
21
22
  appStateDeleteByPrefix,
22
23
  } from "./store.js";
@@ -71,6 +72,17 @@ export async function deleteAppState(key: string): Promise<boolean> {
71
72
  });
72
73
  }
73
74
 
75
+ export async function compareAndSetAppState(
76
+ key: string,
77
+ expectedValue: Record<string, unknown>,
78
+ nextValue: Record<string, unknown> | null,
79
+ ): Promise<boolean> {
80
+ const sessionId = await resolveSessionId();
81
+ return appStateCompareAndSet(sessionId, key, expectedValue, nextValue, {
82
+ requestSource: "agent",
83
+ });
84
+ }
85
+
74
86
  export async function listAppState(
75
87
  prefix: string,
76
88
  ): Promise<Array<{ key: string; value: Record<string, unknown> }>> {
@@ -191,6 +191,44 @@ export async function appStateDelete(
191
191
  return deleted;
192
192
  }
193
193
 
194
+ export async function appStateCompareAndSet(
195
+ sessionId: string,
196
+ key: string,
197
+ expectedValue: Record<string, unknown>,
198
+ nextValue: Record<string, unknown> | null,
199
+ options?: StoreWriteOptions,
200
+ ): Promise<boolean> {
201
+ await ensureTable();
202
+ const client = getDbExec();
203
+ const expected = JSON.stringify(expectedValue);
204
+ if (nextValue === null) {
205
+ const result = await client.execute({
206
+ sql: `DELETE FROM application_state WHERE session_id = ? AND key = ? AND value = ?`,
207
+ args: [sessionId, key, expected],
208
+ });
209
+ const changed = result.rowsAffected > 0;
210
+ if (changed) emitAppStateDelete(key, options?.requestSource, sessionId);
211
+ return changed;
212
+ }
213
+
214
+ const next = JSON.stringify(nextValue);
215
+ if (
216
+ !isLocalDatabase() &&
217
+ Buffer.byteLength(next, "utf8") > MAX_HOSTED_APP_STATE_VALUE_BYTES
218
+ ) {
219
+ throw new Error(
220
+ `application_state value "${key}" is too large for hosted SQL storage. Store large files, base64, or blobs in file storage and write only a URL or handle.`,
221
+ );
222
+ }
223
+ const result = await client.execute({
224
+ sql: `UPDATE application_state SET value = ?, updated_at = ? WHERE session_id = ? AND key = ? AND value = ?`,
225
+ args: [next, Date.now(), sessionId, key, expected],
226
+ });
227
+ const changed = result.rowsAffected > 0;
228
+ if (changed) emitAppStateChange(key, options?.requestSource, sessionId);
229
+ return changed;
230
+ }
231
+
194
232
  export async function appStateList(
195
233
  sessionId: string,
196
234
  keyPrefix: string,
@@ -2,6 +2,7 @@ import { Button } from "@agent-native/toolkit/ui/button";
2
2
  import { Spinner } from "@agent-native/toolkit/ui/spinner";
3
3
  import { Textarea } from "@agent-native/toolkit/ui/textarea";
4
4
  import { IconFocus2, IconMessageCircle, IconSend } from "@tabler/icons-react";
5
+ import type { ReactNode } from "react";
5
6
 
6
7
  import type { ReviewResolutionTarget } from "../../review/types.js";
7
8
  import { cn } from "../utils.js";
@@ -13,12 +14,14 @@ export interface ReviewCommentComposerProps {
13
14
  submittingTarget?: ReviewResolutionTarget | null;
14
15
  disabled?: boolean;
15
16
  showAgentAction?: boolean;
17
+ agentAction?: ReactNode;
16
18
  placeholder?: string;
17
19
  commentLabel?: string;
18
20
  agentLabel?: string;
19
21
  contextLabel?: string;
20
22
  autoFocus?: boolean;
21
23
  submitOnEnter?: boolean;
24
+ enterSubmitTarget?: ReviewResolutionTarget;
22
25
  onEscape?: () => void;
23
26
  className?: string;
24
27
  }
@@ -30,12 +33,14 @@ export function ReviewCommentComposer({
30
33
  submittingTarget = null,
31
34
  disabled = false,
32
35
  showAgentAction = false,
36
+ agentAction,
33
37
  placeholder = "Add a comment...",
34
38
  commentLabel = "Comment",
35
39
  agentLabel = "Send to agent",
36
40
  contextLabel,
37
41
  autoFocus = false,
38
42
  submitOnEnter = false,
43
+ enterSubmitTarget = "human",
39
44
  onEscape,
40
45
  className,
41
46
  }: ReviewCommentComposerProps) {
@@ -75,7 +80,7 @@ export function ReviewCommentComposer({
75
80
  }
76
81
  if (submitOnEnter && event.key === "Enter" && !event.shiftKey) {
77
82
  event.preventDefault();
78
- submit("human");
83
+ submit(enterSubmitTarget);
79
84
  }
80
85
  }}
81
86
  />
@@ -84,7 +89,7 @@ export function ReviewCommentComposer({
84
89
  type="submit"
85
90
  size="sm"
86
91
  disabled={!canSubmit}
87
- className="h-8 w-full min-w-0 gap-1.5 @2xs/review:w-auto"
92
+ className="h-8 w-full gap-1.5 @2xs/review:w-auto @2xs/review:min-w-28 @2xs/review:shrink-0"
88
93
  >
89
94
  {submittingTarget === "human" ? (
90
95
  <Spinner className="size-3.5" />
@@ -93,7 +98,9 @@ export function ReviewCommentComposer({
93
98
  )}
94
99
  <span className="truncate">{commentLabel}</span>
95
100
  </Button>
96
- {showAgentAction ? (
101
+ {showAgentAction && agentAction ? (
102
+ agentAction
103
+ ) : showAgentAction ? (
97
104
  <Button
98
105
  type="button"
99
106
  size="sm"
@@ -47,6 +47,28 @@ iframe-backed screens on the infinite canvas.
47
47
  and create one screen per URL/path. Shorthand like
48
48
  `localhost:1234/onboarding/1` means `http://localhost:1234/onboarding/1`.
49
49
 
50
+ ## Select And Reprompt
51
+
52
+ When a chat message begins with `[Reprompt selection]`, the selected subtree is
53
+ a hard write boundary. The only mutation path is `propose-node-rewrite` with
54
+ the exact `repromptId`, target, and `baseVersionHash` captured in
55
+ `design-reprompt-pending:<designId>:<fileId>`. Never use `apply-visual-edit`,
56
+ `apply-source-edit`, `write-source`, `write-local-file`, `edit-design`, or any
57
+ other content-writing action for that request. Clarifying questions are allowed,
58
+ but a requested change must remain a proposal.
59
+
60
+ Produce one variant by default. Produce two or three only when the instruction
61
+ asks for options. A retry includes `priorProposalId`; keep the same target and
62
+ base version, incorporate the feedback, and call `propose-node-rewrite` again.
63
+ The UI previews the returned subtree without persisting it.
64
+
65
+ Use `resolve-node-rewrite` for the accept/reject lifecycle. Accept applies the
66
+ chosen variant as one version-checked inline/Yjs content transaction so one
67
+ undo restores the prior structure; reject clears the proposal without changing
68
+ content. For conversational resolution such as “apply the second one,” call
69
+ `view-screen`, read the active `design.reprompt.proposal`, and pass its
70
+ `proposalId` plus the zero-based `variantIndex` to `resolve-node-rewrite`.
71
+
50
72
  ## Review Quality
51
73
 
52
74
  - Treat the running app as the truth. Preserve its component language, tokens,
@@ -16,6 +16,13 @@ patterns live in `.agents/skills/`.
16
16
  - Never hardcode API keys, tokens, webhook URLs, signing secrets, private Builder/internal data, customer data, or credential-looking literals. Use secrets/OAuth/runtime configuration and obvious placeholders in examples.
17
17
  - Use the app actions for designs, files, versions, design systems, variants,
18
18
  export, and sharing. Do not write design rows directly with SQL.
19
+ - A message beginning with `[Reprompt selection]` is preview-only. Call
20
+ `propose-node-rewrite` with its exact `repromptId`, target, and base hash;
21
+ never call `edit-design`, `update-design`, `update-file`, `generate-design`,
22
+ `apply-visual-edit`, or another content writer. Only the frontend-only
23
+ `resolve-node-rewrite` action may persist an explicitly accepted proposal.
24
+ - A message beginning with `[Selection question]` is read-only. Answer about
25
+ the captured element and subtree without calling content-writing actions.
19
26
  - When a user wants an established public system as a starting point, call
20
27
  `create-design-system` with `templateId: material-3`, `carbon-white`, or
21
28
  `primer-light`. These are source-linked, versioned token snapshots with
@@ -268,6 +275,15 @@ patterns live in `.agents/skills/`.
268
275
  generation planning state created by `generate-screens` (canvas region
269
276
  assignments and per-frame instructions consumed by `generate-design` and
270
277
  `view-screen`; not rendered as canvas overlays).
278
+ - `design-reprompt-pending:<designId>:<fileId>` is the client-captured source
279
+ selection, instruction, base hash, and authoritative current request id for
280
+ a scoped regenerate request.
281
+ - `design-reprompt-proposal:<designId>:<fileId>:<repromptId>` is one
282
+ request-specific preview-only subtree proposal. Candidate payloads have a
283
+ 256 KiB aggregate serialized limit. Resolution and cancellation use atomic
284
+ compare-and-set cleanup so an older request cannot erase a newer one.
285
+ `view-screen` lists only proposals paired to the current pending request as
286
+ `pendingCandidateReviews`.
271
287
  - `show-design-questions` opens focused pre-generation questions in the main
272
288
  design canvas (`show-questions` application state).
273
289
  - `guided-questions` may contain a one-click chat choice for the current
@@ -0,0 +1,54 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import {
3
+ compareAndSetAppState,
4
+ readAppState,
5
+ } from "@agent-native/core/application-state";
6
+ import { assertAccess } from "@agent-native/core/sharing";
7
+ import { z } from "zod";
8
+
9
+ import {
10
+ designRepromptPendingStateKey,
11
+ designRepromptProposalStateKey,
12
+ isNodeRewriteProposal,
13
+ isPendingDesignReprompt,
14
+ } from "../shared/node-rewrite.js";
15
+
16
+ export default defineAction({
17
+ agentTool: false,
18
+ description:
19
+ "Cancel one pending node rewrite only when it is still the current request.",
20
+ schema: z.object({
21
+ designId: z.string().min(1),
22
+ fileId: z.string().min(1),
23
+ repromptId: z.string().min(1),
24
+ }),
25
+ run: async ({ designId, fileId, repromptId }) => {
26
+ await assertAccess("design", designId, "editor");
27
+ const pendingKey = designRepromptPendingStateKey(designId, fileId);
28
+ const pending = await readAppState(pendingKey);
29
+ if (
30
+ !isPendingDesignReprompt(pending) ||
31
+ pending.repromptId !== repromptId
32
+ ) {
33
+ return { cancelled: false, superseded: true };
34
+ }
35
+
36
+ const proposalKey = designRepromptProposalStateKey(
37
+ designId,
38
+ fileId,
39
+ repromptId,
40
+ );
41
+ const proposal = await readAppState(proposalKey);
42
+ const [pendingCancelled, proposalCancelled] = await Promise.all([
43
+ compareAndSetAppState(pendingKey, pending, null),
44
+ isNodeRewriteProposal(proposal)
45
+ ? compareAndSetAppState(proposalKey, proposal, null)
46
+ : Promise.resolve(false),
47
+ ]);
48
+ return {
49
+ cancelled: pendingCancelled,
50
+ proposalCancelled,
51
+ superseded: !pendingCancelled,
52
+ };
53
+ },
54
+ });
@@ -0,0 +1,290 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import {
3
+ compareAndSetAppState,
4
+ readAppState,
5
+ writeAppState,
6
+ } from "@agent-native/core/application-state";
7
+ import { accessFilter, assertAccess } from "@agent-native/core/sharing";
8
+ import { and, eq } from "drizzle-orm";
9
+ import { nanoid } from "nanoid";
10
+ import { z } from "zod";
11
+
12
+ import { getDb, schema } from "../server/db/index.js";
13
+ import {
14
+ readLiveSourceFile,
15
+ type SourceWorkspaceFile,
16
+ } from "../server/source-workspace.js";
17
+ import type { CodeLayerSource } from "../shared/code-layer.js";
18
+ import {
19
+ designRepromptPendingStateKey,
20
+ designRepromptProposalStateKey,
21
+ MAX_NODE_REWRITE_PROPOSAL_BYTES,
22
+ resolveNodeRewriteTarget,
23
+ validateNodeRewriteVariant,
24
+ type NodeHtmlPreviewBridgeMessage,
25
+ type NodeRewriteProposal,
26
+ type NodeRewriteTarget,
27
+ } from "../shared/node-rewrite.js";
28
+ import { designSourceTypeFromData } from "../shared/source-mode.js";
29
+
30
+ const sourceSchema = z
31
+ .object({
32
+ designId: z.string().min(1).optional(),
33
+ fileId: z.string().min(1).optional(),
34
+ filename: z.string().min(1).optional(),
35
+ })
36
+ .superRefine((source, ctx) => {
37
+ if (!source.designId && !source.fileId) {
38
+ ctx.addIssue({
39
+ code: z.ZodIssueCode.custom,
40
+ path: ["designId"],
41
+ message: "source.designId or source.fileId is required",
42
+ });
43
+ }
44
+ });
45
+
46
+ const targetSchema = z
47
+ .object({
48
+ nodeId: z.string().min(1).optional(),
49
+ selector: z.string().min(1).optional(),
50
+ })
51
+ .superRefine((target, ctx) => {
52
+ if (!target.nodeId && !target.selector) {
53
+ ctx.addIssue({
54
+ code: z.ZodIssueCode.custom,
55
+ path: ["nodeId"],
56
+ message: "target.nodeId or target.selector is required",
57
+ });
58
+ }
59
+ });
60
+
61
+ const variantSchema = z.object({
62
+ html: z
63
+ .string()
64
+ .min(1)
65
+ .max(250_000)
66
+ .describe("Replacement outerHTML for the selected subtree"),
67
+ summary: z.string().trim().min(1).max(240),
68
+ });
69
+
70
+ const pendingRepromptSchema = z.object({
71
+ repromptId: z.string().min(1),
72
+ designId: z.string().min(1),
73
+ fileId: z.string().min(1),
74
+ target: targetSchema,
75
+ baseVersionHash: z.string().min(1),
76
+ instruction: z.string(),
77
+ createdAt: z.string(),
78
+ priorProposalId: z.string().min(1).optional(),
79
+ priorRepromptId: z.string().min(1).optional(),
80
+ });
81
+
82
+ interface EditableDesignFile extends SourceWorkspaceFile {
83
+ designData: string | null;
84
+ }
85
+
86
+ async function resolveEditableDesignFile(source: {
87
+ designId?: string;
88
+ fileId?: string;
89
+ filename?: string;
90
+ }): Promise<EditableDesignFile> {
91
+ const db = getDb();
92
+ const conditions = [
93
+ accessFilter(schema.designs, schema.designShares),
94
+ source.fileId
95
+ ? eq(schema.designFiles.id, source.fileId)
96
+ : eq(schema.designFiles.designId, source.designId ?? ""),
97
+ ];
98
+ if (!source.fileId) {
99
+ conditions.push(
100
+ eq(schema.designFiles.filename, source.filename ?? "index.html"),
101
+ );
102
+ }
103
+
104
+ const [file] = await db
105
+ .select({
106
+ id: schema.designFiles.id,
107
+ designId: schema.designFiles.designId,
108
+ filename: schema.designFiles.filename,
109
+ fileType: schema.designFiles.fileType,
110
+ content: schema.designFiles.content,
111
+ createdAt: schema.designFiles.createdAt,
112
+ updatedAt: schema.designFiles.updatedAt,
113
+ designData: schema.designs.data,
114
+ })
115
+ .from(schema.designFiles)
116
+ .innerJoin(
117
+ schema.designs,
118
+ eq(schema.designFiles.designId, schema.designs.id),
119
+ )
120
+ .where(and(...conditions))
121
+ .limit(1);
122
+
123
+ if (!file) throw new Error("Design HTML file not found.");
124
+ if (source.designId && source.designId !== file.designId) {
125
+ throw new Error(
126
+ `source.designId "${source.designId}" does not match file "${file.id}"`,
127
+ );
128
+ }
129
+ if (source.filename && source.filename !== file.filename) {
130
+ throw new Error(
131
+ `source.filename "${source.filename}" does not match file "${file.id}"`,
132
+ );
133
+ }
134
+ if (file.fileType !== "html") {
135
+ throw new Error("Node rewrites only support HTML design files.");
136
+ }
137
+ if (designSourceTypeFromData(file.designData) !== "inline") {
138
+ throw new Error("Node rewrites only support inline design screens.");
139
+ }
140
+ await assertAccess("design", file.designId, "editor");
141
+ return file;
142
+ }
143
+
144
+ function targetsMatch(a: NodeRewriteTarget, b: NodeRewriteTarget): boolean {
145
+ const nodeIdsConflict = a.nodeId && b.nodeId && a.nodeId !== b.nodeId;
146
+ const selectorsConflict =
147
+ a.selector && b.selector && a.selector !== b.selector;
148
+ if (nodeIdsConflict || selectorsConflict) return false;
149
+ return Boolean(
150
+ (a.nodeId && b.nodeId && a.nodeId === b.nodeId) ||
151
+ (a.selector && b.selector && a.selector === b.selector),
152
+ );
153
+ }
154
+
155
+ export default defineAction({
156
+ description:
157
+ "Propose one to three scoped HTML rewrites for a pending Design selection. " +
158
+ "This previews only: it stores proposal state and never changes the design file. " +
159
+ "The target must identify, and the reprompt id and base hash must exactly match, the pending client selection.",
160
+ schema: z.object({
161
+ source: sourceSchema,
162
+ target: targetSchema,
163
+ baseVersionHash: z.string().min(1),
164
+ variants: z.array(variantSchema).min(1).max(3),
165
+ repromptId: z.string().min(1),
166
+ }),
167
+ run: async ({ source, target, baseVersionHash, variants, repromptId }) => {
168
+ const file = await resolveEditableDesignFile(source);
169
+ const sourceDescriptor: CodeLayerSource = {
170
+ kind: "design-file",
171
+ sourceType: "inline",
172
+ designId: file.designId,
173
+ fileId: file.id,
174
+ filename: file.filename,
175
+ };
176
+ const live = await readLiveSourceFile(file);
177
+ if (live.versionHash !== baseVersionHash) {
178
+ throw new Error(
179
+ "Screen changed since the selection was captured — regenerate the proposal.",
180
+ );
181
+ }
182
+
183
+ const pendingKey = designRepromptPendingStateKey(file.designId, file.id);
184
+ const pending = pendingRepromptSchema.parse(await readAppState(pendingKey));
185
+ if (pending.repromptId !== repromptId) {
186
+ throw new Error("Pending reprompt does not match this request.");
187
+ }
188
+ if (
189
+ pending.designId !== file.designId ||
190
+ pending.fileId !== file.id ||
191
+ !targetsMatch(pending.target, target)
192
+ ) {
193
+ throw new Error(
194
+ "The proposed target does not match the pending selected subtree.",
195
+ );
196
+ }
197
+ if (pending.baseVersionHash !== baseVersionHash) {
198
+ throw new Error(
199
+ "The proposal base hash does not match the pending selection.",
200
+ );
201
+ }
202
+
203
+ const authoritativeTarget = pending.target;
204
+ const node = resolveNodeRewriteTarget(
205
+ live.content,
206
+ authoritativeTarget,
207
+ sourceDescriptor,
208
+ );
209
+ const validatedVariants = variants.map((variant) =>
210
+ validateNodeRewriteVariant(variant, sourceDescriptor),
211
+ );
212
+ const proposalId = `node-rewrite-${nanoid()}`;
213
+ const resolvedTarget = {
214
+ nodeId: node.id,
215
+ selector: node.selector,
216
+ };
217
+ const proposal: NodeRewriteProposal = {
218
+ proposalId,
219
+ repromptId,
220
+ designId: file.designId,
221
+ fileId: file.id,
222
+ filename: file.filename,
223
+ target: authoritativeTarget,
224
+ resolvedTarget,
225
+ baseVersionHash,
226
+ variants: validatedVariants,
227
+ chosenIndex: 0,
228
+ createdAt: new Date().toISOString(),
229
+ };
230
+ const serializedProposal = JSON.stringify(proposal);
231
+ if (
232
+ Buffer.byteLength(serializedProposal, "utf8") >
233
+ MAX_NODE_REWRITE_PROPOSAL_BYTES
234
+ ) {
235
+ throw new Error(
236
+ "Generated candidates are too large to preview safely. Use fewer variants or regenerate a smaller selected subtree.",
237
+ );
238
+ }
239
+ const proposalKey = designRepromptProposalStateKey(
240
+ file.designId,
241
+ file.id,
242
+ repromptId,
243
+ );
244
+ await writeAppState(
245
+ proposalKey,
246
+ proposal as unknown as Record<string, unknown>,
247
+ );
248
+ const currentPending = await readAppState(pendingKey);
249
+ if (currentPending?.repromptId !== repromptId) {
250
+ await compareAndSetAppState(
251
+ proposalKey,
252
+ proposal as unknown as Record<string, unknown>,
253
+ null,
254
+ );
255
+ throw new Error(
256
+ "This regeneration was superseded by a newer request before its candidates were published.",
257
+ );
258
+ }
259
+ if (pending.priorProposalId && pending.priorRepromptId) {
260
+ const priorProposalKey = designRepromptProposalStateKey(
261
+ file.designId,
262
+ file.id,
263
+ pending.priorRepromptId,
264
+ );
265
+ const priorProposal = await readAppState(priorProposalKey);
266
+ if (priorProposal?.proposalId === pending.priorProposalId) {
267
+ await compareAndSetAppState(priorProposalKey, priorProposal, null);
268
+ }
269
+ }
270
+
271
+ const bridgeMessages: NodeHtmlPreviewBridgeMessage[] = [
272
+ {
273
+ type: "node-html-preview",
274
+ proposalId,
275
+ target: resolvedTarget,
276
+ html: validatedVariants[0]!.html,
277
+ operation: "preview",
278
+ },
279
+ ];
280
+ return {
281
+ proposalId,
282
+ repromptId,
283
+ designId: file.designId,
284
+ fileId: file.id,
285
+ target: resolvedTarget,
286
+ variants: validatedVariants,
287
+ bridgeMessages,
288
+ };
289
+ },
290
+ });