@agent-native/core 0.120.0 → 0.120.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 (55) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +7 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/MultiTabAssistantChat.tsx +45 -0
  5. package/corpus/core/src/client/use-chat-models.ts +45 -0
  6. package/corpus/core/src/server/analytics.ts +15 -0
  7. package/corpus/core/src/server/auth.ts +17 -13
  8. package/corpus/core/src/server/better-auth-instance.ts +13 -1
  9. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +1 -1
  10. package/corpus/templates/assets/changelog/2026-07-23-new-chat-aligns-with-sidebar-controls.md +6 -0
  11. package/corpus/templates/forms/.agents/skills/form-publishing/SKILL.md +9 -3
  12. package/corpus/templates/forms/AGENTS.md +11 -1
  13. package/corpus/templates/forms/README.md +24 -1
  14. package/corpus/templates/forms/actions/update-form.ts +12 -5
  15. package/corpus/templates/forms/app/components/builder/FieldPropertiesPanel.tsx +199 -1
  16. package/corpus/templates/forms/app/i18n/en-US.ts +13 -1
  17. package/corpus/templates/forms/app/pages/FormBuilderPage.tsx +35 -4
  18. package/corpus/templates/forms/app/pages/FormFillPage.tsx +7 -16
  19. package/corpus/templates/forms/changelog/2026-07-23-forms-can-branch-into-follow-up-questions-and-route-response.md +6 -0
  20. package/corpus/templates/forms/server/handlers/submissions.ts +22 -31
  21. package/corpus/templates/forms/server/lib/integrations.ts +11 -2
  22. package/corpus/templates/forms/server/lib/public-form-ssr.ts +26 -5
  23. package/corpus/templates/forms/server/lib/validate-fields.ts +48 -11
  24. package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -0
  25. package/corpus/templates/forms/shared/conditional.ts +67 -0
  26. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  27. package/dist/client/MultiTabAssistantChat.js +35 -0
  28. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  29. package/dist/client/use-chat-models.d.ts +1 -0
  30. package/dist/client/use-chat-models.d.ts.map +1 -1
  31. package/dist/client/use-chat-models.js +33 -0
  32. package/dist/client/use-chat-models.js.map +1 -1
  33. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  34. package/dist/observability/routes.d.ts +2 -2
  35. package/dist/progress/routes.d.ts +1 -1
  36. package/dist/provider-api/actions/custom-provider-registration.d.ts +6 -6
  37. package/dist/provider-api/actions/provider-api.d.ts +9 -9
  38. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  39. package/dist/server/analytics.d.ts +8 -0
  40. package/dist/server/analytics.d.ts.map +1 -1
  41. package/dist/server/analytics.js +16 -0
  42. package/dist/server/analytics.js.map +1 -1
  43. package/dist/server/auth.d.ts.map +1 -1
  44. package/dist/server/auth.js +5 -4
  45. package/dist/server/auth.js.map +1 -1
  46. package/dist/server/better-auth-instance.d.ts +8 -0
  47. package/dist/server/better-auth-instance.d.ts.map +1 -1
  48. package/dist/server/better-auth-instance.js +10 -1
  49. package/dist/server/better-auth-instance.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/client/MultiTabAssistantChat.tsx +45 -0
  52. package/src/client/use-chat-models.ts +45 -0
  53. package/src/server/analytics.ts +15 -0
  54. package/src/server/auth.ts +17 -13
  55. package/src/server/better-auth-instance.ts +13 -1
package/corpus/README.md CHANGED
@@ -30,4 +30,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
30
30
 
31
31
  - core files: 1662
32
32
  - toolkit files: 159
33
- - template files: 6334
33
+ - template files: 6337
@@ -1,5 +1,12 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.120.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5477352: Keep Dispatch's overview composer, full-page chat, and side chat on the same selected model.
8
+ - 5477352: Inject the configured Google Analytics tag into framework-owned login and signup pages.
9
+
3
10
  ## 0.120.0
4
11
 
5
12
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.120.0",
3
+ "version": "0.120.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": {
@@ -56,6 +56,7 @@ import { isTrustedFrameMessage } from "./frame.js";
56
56
  import { RunStuckBanner } from "./RunStuckBanner.js";
57
57
  import { callAction } from "./use-action.js";
58
58
  import { useChangeVersion } from "./use-change-version.js";
59
+ import { CHAT_MODEL_SELECTION_CHANGED_EVENT } from "./use-chat-models.js";
59
60
  import {
60
61
  useChatThreads,
61
62
  type ChatThreadScope,
@@ -138,6 +139,13 @@ function writeStoredModelSelection(key: string, selection: ModelSelection) {
138
139
  if (typeof window === "undefined") return;
139
140
  try {
140
141
  window.localStorage.setItem(key, JSON.stringify(selection));
142
+ queueMicrotask(() => {
143
+ window.dispatchEvent(
144
+ new CustomEvent(CHAT_MODEL_SELECTION_CHANGED_EVENT, {
145
+ detail: { key },
146
+ }),
147
+ );
148
+ });
141
149
  } catch {}
142
150
  }
143
151
 
@@ -954,6 +962,43 @@ export function MultiTabAssistantChat({
954
962
  const bumpModelSelectionVersion = useCallback(() => {
955
963
  setModelSelectionVersion((version) => version + 1);
956
964
  }, []);
965
+
966
+ useEffect(() => {
967
+ if (typeof window === "undefined") return;
968
+
969
+ const syncPersistedSelection = (event?: Event) => {
970
+ const detail = (event as CustomEvent<{ key?: string }> | undefined)
971
+ ?.detail;
972
+ if (detail?.key && detail.key !== modelSelectionKey) return;
973
+
974
+ const next = readStoredModelSelection(modelSelectionKey);
975
+ if (!next) return;
976
+
977
+ const activeThreadId = activeThreadIdRef.current;
978
+ if (activeThreadId) {
979
+ threadModelRef.current.set(activeThreadId, next);
980
+ }
981
+ setPersistedModelSelection(next);
982
+ bumpModelSelectionVersion();
983
+ };
984
+ const handleStorage = (event: StorageEvent) => {
985
+ if (event.key === modelSelectionKey) syncPersistedSelection();
986
+ };
987
+
988
+ window.addEventListener(
989
+ CHAT_MODEL_SELECTION_CHANGED_EVENT,
990
+ syncPersistedSelection,
991
+ );
992
+ window.addEventListener("storage", handleStorage);
993
+ return () => {
994
+ window.removeEventListener(
995
+ CHAT_MODEL_SELECTION_CHANGED_EVENT,
996
+ syncPersistedSelection,
997
+ );
998
+ window.removeEventListener("storage", handleStorage);
999
+ };
1000
+ }, [bumpModelSelectionVersion, modelSelectionKey]);
1001
+
957
1002
  const postMessageSubmissionsDisabled = props.composerDisabled === true;
958
1003
 
959
1004
  const setContextInTab = useCallback(
@@ -42,6 +42,8 @@ interface Options {
42
42
  }
43
43
 
44
44
  const DEFAULT_STORAGE_KEY = "agent-native:chat-models:selection";
45
+ export const CHAT_MODEL_SELECTION_CHANGED_EVENT =
46
+ "agent-native:chat-model-selection-changed";
45
47
 
46
48
  interface PersistedSelection {
47
49
  model?: string;
@@ -63,6 +65,13 @@ function writePersisted(key: string | null, value: PersistedSelection) {
63
65
  if (!key || typeof window === "undefined") return;
64
66
  try {
65
67
  window.localStorage.setItem(key, JSON.stringify(value));
68
+ queueMicrotask(() => {
69
+ window.dispatchEvent(
70
+ new CustomEvent(CHAT_MODEL_SELECTION_CHANGED_EVENT, {
71
+ detail: { key },
72
+ }),
73
+ );
74
+ });
66
75
  } catch {}
67
76
  }
68
77
 
@@ -110,6 +119,42 @@ export function useChatModels({
110
119
  };
111
120
  }, [selectedEffort, selectedEngine, selectedModel]);
112
121
 
122
+ useEffect(() => {
123
+ if (!storageKey || typeof window === "undefined") return;
124
+
125
+ const syncPersistedSelection = (event?: Event) => {
126
+ const detail = (event as CustomEvent<{ key?: string }> | undefined)
127
+ ?.detail;
128
+ if (detail?.key && detail.key !== storageKey) return;
129
+
130
+ const next = readPersisted(storageKey);
131
+ if (!next.model) return;
132
+
133
+ hasExplicitSelectionRef.current = true;
134
+ setSelectedModel(next.model);
135
+ setSelectedEngine(next.engine ?? "");
136
+ setSelectedEffort(
137
+ resolveReasoningEffortSelection(next.model, next.effort),
138
+ );
139
+ };
140
+ const handleStorage = (event: StorageEvent) => {
141
+ if (event.key === storageKey) syncPersistedSelection();
142
+ };
143
+
144
+ window.addEventListener(
145
+ CHAT_MODEL_SELECTION_CHANGED_EVENT,
146
+ syncPersistedSelection,
147
+ );
148
+ window.addEventListener("storage", handleStorage);
149
+ return () => {
150
+ window.removeEventListener(
151
+ CHAT_MODEL_SELECTION_CHANGED_EVENT,
152
+ syncPersistedSelection,
153
+ );
154
+ window.removeEventListener("storage", handleStorage);
155
+ };
156
+ }, [storageKey]);
157
+
113
158
  const onModelChange = useCallback(
114
159
  (model: string, engine: string) => {
115
160
  hasExplicitSelectionRef.current = true;
@@ -74,6 +74,21 @@ function getGaScript(): string | null {
74
74
  );
75
75
  }
76
76
 
77
+ /**
78
+ * Add the configured analytics scripts to a complete HTML document.
79
+ *
80
+ * The normal app document is streamed through `wrapWithAnalytics`, but
81
+ * framework-owned documents such as `/signup` are returned as strings by the
82
+ * auth guard and need the same injection path.
83
+ */
84
+ export function injectAnalyticsIntoHtml(html: string): string {
85
+ const script = getGaScript();
86
+ if (!script) return html;
87
+ const headCloseIdx = html.indexOf("</head>");
88
+ if (headCloseIdx === -1) return html;
89
+ return html.slice(0, headCloseIdx) + script + html.slice(headCloseIdx);
90
+ }
91
+
77
92
  export function wrapWithAnalytics(body: ReadableStream): ReadableStream {
78
93
  const scripts = [getGaScript()].filter(Boolean).join("");
79
94
  if (!scripts) return body;
@@ -98,6 +98,7 @@ import {
98
98
  type WorkspaceAppAudience,
99
99
  } from "../shared/workspace-app-audience.js";
100
100
  import { isValidWorkspaceAppIdFormat } from "../shared/workspace-app-id.js";
101
+ import { injectAnalyticsIntoHtml } from "./analytics.js";
101
102
  import { signupAttributionFromCookieHeader } from "./attribution.js";
102
103
  import { getBetterAuth, getBetterAuthSync } from "./better-auth-instance.js";
103
104
  import type { BetterAuthConfig } from "./better-auth-instance.js";
@@ -1646,20 +1647,23 @@ function injectLoginSocialImageMeta(loginHtml: string, event: H3Event): string {
1646
1647
  }
1647
1648
 
1648
1649
  function loginHtmlResponse(loginHtml: string, event: H3Event): Response {
1649
- return new Response(injectLoginSocialImageMeta(loginHtml, event), {
1650
- status: 200,
1651
- headers: {
1652
- "Content-Type": "text/html; charset=utf-8",
1653
- // The sign-in document is part of the public server shell. Keep it on the
1654
- // same long-fresh/long-SWR CDN policy as React Router SSR so hosted
1655
- // template roots do not invoke origin just to render anonymous login UI.
1656
- // The login HTML is env-INDEPENDENT (a Google-only app always renders a
1657
- // working button), so a cached copy is never "wrong" never downgrade
1658
- // this to private/no-store.
1659
- ...DEFAULT_SSR_CACHE_HEADERS,
1660
- "X-Robots-Tag": "noindex, nofollow",
1650
+ return new Response(
1651
+ injectAnalyticsIntoHtml(injectLoginSocialImageMeta(loginHtml, event)),
1652
+ {
1653
+ status: 200,
1654
+ headers: {
1655
+ "Content-Type": "text/html; charset=utf-8",
1656
+ // The sign-in document is part of the public server shell. Keep it on the
1657
+ // same long-fresh/long-SWR CDN policy as React Router SSR so hosted
1658
+ // template roots do not invoke origin just to render anonymous login UI.
1659
+ // The login markup is env-INDEPENDENT (a Google-only app always renders
1660
+ // a working button); the analytics script is public build configuration,
1661
+ // not user/session state. Never downgrade this to private/no-store.
1662
+ ...DEFAULT_SSR_CACHE_HEADERS,
1663
+ "X-Robots-Tag": "noindex, nofollow",
1664
+ },
1661
1665
  },
1662
- });
1666
+ );
1663
1667
  }
1664
1668
 
1665
1669
  function isHtmlDocumentRequest(event: H3Event, pathname: string): boolean {
@@ -1109,6 +1109,18 @@ async function createBetterAuthInstance(
1109
1109
  return auth as unknown as BetterAuthInstance;
1110
1110
  }
1111
1111
 
1112
+ /**
1113
+ * Configure the local auth connection with the same write contention settings
1114
+ * as the shared app connection. Better Auth uses its own SQLite handle, so the
1115
+ * app connection's busy timeout does not protect first-run account creation.
1116
+ */
1117
+ export function configureLocalSqlite(sqlite: {
1118
+ pragma(statement: string): unknown;
1119
+ }): void {
1120
+ sqlite.pragma("busy_timeout = 10000");
1121
+ sqlite.pragma("journal_mode = WAL");
1122
+ }
1123
+
1112
1124
  async function buildDatabaseConfig(
1113
1125
  dialect: string,
1114
1126
  ): Promise<BetterAuthOptions["database"]> {
@@ -1182,7 +1194,7 @@ async function buildDatabaseConfig(
1182
1194
  const { default: Database } = await import("better-sqlite3");
1183
1195
  const filePath = url.replace(/^file:/, "");
1184
1196
  const sqlite = new Database(filePath);
1185
- sqlite.pragma("journal_mode = WAL");
1197
+ configureLocalSqlite(sqlite);
1186
1198
  const { drizzle } = await import("drizzle-orm/better-sqlite3");
1187
1199
  const db = drizzle(sqlite, { schema: sqliteAuthSchema });
1188
1200
  const { drizzleAdapter } = await import("better-auth/adapters/drizzle");
@@ -283,7 +283,7 @@ function AssetsChatsSection() {
283
283
  unpin: t("chat.unpinChat"),
284
284
  delete: t("chat.archiveChat"),
285
285
  }}
286
- className="min-w-0"
286
+ className="min-w-0 [&_.an-chat-history-rail__new-chat]:justify-start"
287
287
  />
288
288
  </div>
289
289
  );
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-07-23
4
+ ---
5
+
6
+ New chat is aligned consistently with the rest of the Assets sidebar.
@@ -77,7 +77,7 @@ Each form has a `settings` JSON object:
77
77
  | `showProgressBar` | boolean | Show progress bar for multi-section forms |
78
78
  | `emailOnNewResponses` | boolean | Email the form owner's account when someone submits a response |
79
79
  | `anonymous` | boolean | Suppress IP, submitter identity, chat/run ids, page URL, and client-surface metadata for every response |
80
- | `integrations` | array | Webhook/Slack/Discord notification configs |
80
+ | `integrations` | array | Webhook/Slack/Discord/Google Sheets notification configs |
81
81
 
82
82
  For a genuinely anonymous form, set `anonymous: true` when creating the form.
83
83
  Do not describe an ordinary published form as anonymous: published forms accept
@@ -91,9 +91,15 @@ Forms can notify external services on submission:
91
91
  | Type | Description |
92
92
  | --------------- | ------------------------------- |
93
93
  | `webhook` | POST JSON to any URL |
94
- | `slack` | Send to a Slack channel |
94
+ | `slack` | Send to a Slack channel through an Incoming Webhook URL |
95
95
  | `discord` | Send to a Discord webhook |
96
- | `google-sheets` | Append row to a Google Sheet |
96
+ | `google-sheets` | Send response JSON to a deployed Apps Script `/exec` URL |
97
+
98
+ These are outbound form destinations configured in the form builder's
99
+ **Integrations** tab. They are separate from the managed Slack/Messaging
100
+ connection. Google Sheets Apps Script handlers should parse
101
+ `JSON.parse(e.postData.contents)`; a spreadsheet URL or `/dev` URL will not
102
+ receive submissions.
97
103
 
98
104
  ## Related Skills
99
105
 
@@ -40,7 +40,17 @@ ladder.
40
40
  - To email the form owner when someone submits a response, set
41
41
  `settings.emailOnNewResponses: true` through `create-form` or `update-form`.
42
42
  Delivery uses the configured framework email provider (`RESEND_API_KEY` or
43
- `SENDGRID_API_KEY`) and sends to the form owner's account email.
43
+ `SENDGRID_API_KEY`) in the form owner's request context and sends to the form
44
+ owner's account email. A public submission can succeed even when delivery
45
+ fails, so check the server logs and provider configuration when debugging.
46
+ - Conditional fields use `conditional: { fieldId, operator, value }`. The
47
+ `fieldId` must reference an earlier field; supported operators are `equals`,
48
+ `not_equals`, and `contains`. Hidden fields and their stale values are not
49
+ persisted or delivered to integrations.
50
+ - Form integrations are outbound webhooks. Slack requires an Incoming Webhook
51
+ URL, and Google Sheets requires a deployed Google Apps Script `/exec` URL that
52
+ parses `JSON.parse(e.postData.contents)` and appends the received values.
53
+ They are separate from the managed Slack/Messaging connection.
44
54
  - Form UX should stay focused: clear labels, sensible validation, minimal
45
55
  required fields, and progressive disclosure for advanced settings.
46
56
  - Public form submission endpoints must be intentionally public; keep management
@@ -17,7 +17,8 @@ them.
17
17
  - Shipped field types: text, email, number, long text, select, multi-select,
18
18
  checkbox, radio, date, rating, and scale.
19
19
  - Submissions stored in SQL with a per-response detail view and dashboard.
20
- - Route submissions to webhooks, Slack, Discord, or Google Sheets.
20
+ - Route submissions to webhooks, Slack, Discord, or Google Sheets via Apps
21
+ Script.
21
22
  - Publish public form URLs with a thank-you message.
22
23
 
23
24
  ## Develop locally
@@ -32,3 +33,25 @@ pnpm dev
32
33
  ```
33
34
 
34
35
  Full docs: [agent-native.com/docs/template-forms](https://agent-native.com/docs/template-forms).
36
+
37
+ ## Submission destinations
38
+
39
+ Open a form's **Integrations** tab and save a destination before publishing.
40
+ Slack uses an Incoming Webhook URL. Google Sheets uses a deployed Apps Script
41
+ web app URL ending in `/exec` (not a spreadsheet URL or `/dev` URL). The script
42
+ should parse the JSON body and append the values, for example:
43
+
44
+ ```ts
45
+ function doPost(e) {
46
+ const payload = JSON.parse(e.postData.contents);
47
+ SpreadsheetApp.getActiveSpreadsheet()
48
+ .getSheets()[0]
49
+ .appendRow([payload.submittedAt, payload.formTitle, payload.responseId]);
50
+ return ContentService.createTextOutput(JSON.stringify({ ok: true }));
51
+ }
52
+ ```
53
+
54
+ Deploy it as a web app that executes as you and allows anyone with the URL to
55
+ access it, then paste the deployed `/exec` URL into Forms. The destination
56
+ receives the form metadata plus one property per field label; duplicate labels
57
+ are disambiguated with the field ID.
@@ -85,22 +85,29 @@ export default defineAction({
85
85
  updates.fields = JSON.stringify(parsedFields);
86
86
  }
87
87
  if (args.settings !== undefined) {
88
- let parsedSettings: FormSettings;
88
+ let incomingSettings: FormSettings;
89
89
  if (typeof args.settings === "string") {
90
90
  try {
91
- parsedSettings = JSON.parse(args.settings) as FormSettings;
92
- updates.settings = args.settings;
91
+ incomingSettings = JSON.parse(args.settings) as FormSettings;
93
92
  } catch {
94
93
  throw new Error("--settings must be valid JSON");
95
94
  }
96
95
  } else {
97
- parsedSettings = args.settings as unknown as FormSettings;
98
- updates.settings = JSON.stringify(args.settings);
96
+ incomingSettings = args.settings as unknown as FormSettings;
99
97
  }
98
+ let existingSettings: FormSettings = {};
99
+ try {
100
+ existingSettings = JSON.parse(existing.settings) as FormSettings;
101
+ } catch {
102
+ // Keep malformed legacy settings recoverable by replacing them with
103
+ // the valid settings supplied by this update.
104
+ }
105
+ const parsedSettings = { ...existingSettings, ...incomingSettings };
100
106
  // Reject blocked integration URLs at save time (private IPs,
101
107
  // cloud-metadata, non-http(s) schemes). fireIntegrations also
102
108
  // re-checks at runtime as defense-in-depth.
103
109
  assertIntegrationUrlsAllowed(parsedSettings);
110
+ updates.settings = JSON.stringify(parsedSettings);
104
111
  }
105
112
  if (args.status !== undefined) updates.status = args.status;
106
113
 
@@ -1,5 +1,5 @@
1
1
  import { useT } from "@agent-native/core/client/i18n";
2
- import type { FormField, FormFieldType } from "@shared/types";
2
+ import type { ConditionalRule, FormField, FormFieldType } from "@shared/types";
3
3
  import { IconPlus, IconX } from "@tabler/icons-react";
4
4
  import { useState } from "react";
5
5
 
@@ -19,6 +19,7 @@ import { Textarea } from "@/components/ui/textarea";
19
19
 
20
20
  interface FieldPropertiesPanelProps {
21
21
  field: FormField;
22
+ fields: FormField[];
22
23
  onChange: (field: FormField) => void;
23
24
  onDelete: () => void;
24
25
  }
@@ -39,13 +40,48 @@ const fieldTypeLabels: Record<FormFieldType, string> = {
39
40
 
40
41
  const hasOptions: FormFieldType[] = ["select", "multiselect", "radio"];
41
42
 
43
+ function conditionValueOptions(source: FormField | undefined): string[] {
44
+ if (!source) return [];
45
+ if (hasOptions.includes(source.type)) {
46
+ return (source.options || []).filter(
47
+ (option, index, options) =>
48
+ Boolean(option.trim()) && options.indexOf(option) === index,
49
+ );
50
+ }
51
+ if (source.type === "checkbox") return ["true", "false"];
52
+ if (source.type === "rating") return ["1", "2", "3", "4", "5"];
53
+ return [];
54
+ }
55
+
56
+ function defaultConditionOperator(
57
+ source: FormField,
58
+ ): ConditionalRule["operator"] {
59
+ return source.type === "multiselect" ? "contains" : "equals";
60
+ }
61
+
42
62
  export function FieldPropertiesPanel({
43
63
  field,
64
+ fields,
44
65
  onChange,
45
66
  onDelete,
46
67
  }: FieldPropertiesPanelProps) {
47
68
  const t = useT();
48
69
  const [newOption, setNewOption] = useState("");
70
+ const fieldIndex = fields.findIndex((candidate) => candidate.id === field.id);
71
+ const availableFields =
72
+ fieldIndex >= 0
73
+ ? fields.slice(0, fieldIndex)
74
+ : fields.filter((candidate) => candidate.id !== field.id);
75
+ const conditionSource =
76
+ availableFields.find(
77
+ (candidate) => candidate.id === field.conditional?.fieldId,
78
+ ) || availableFields[0];
79
+ const conditionOptions = conditionValueOptions(conditionSource);
80
+ const conditionFieldId = conditionSource?.id || "";
81
+ const conditionOperator =
82
+ field.conditional?.operator ||
83
+ (conditionSource ? defaultConditionOperator(conditionSource) : "equals");
84
+ const conditionValue = field.conditional?.value || conditionOptions[0] || "";
49
85
 
50
86
  function update(partial: Partial<FormField>) {
51
87
  onChange({ ...field, ...partial });
@@ -161,6 +197,168 @@ export function FieldPropertiesPanel({
161
197
  </Select>
162
198
  </div>
163
199
 
200
+ {availableFields.length > 0 && (
201
+ <>
202
+ <Separator />
203
+ <div className="space-y-3">
204
+ <div className="flex items-start justify-between gap-3">
205
+ <div className="space-y-0.5">
206
+ <Label className="text-xs">
207
+ {t("fieldProperties.conditionalVisibility")}
208
+ </Label>
209
+ <p className="text-[11px] leading-snug text-muted-foreground">
210
+ {t("fieldProperties.conditionalVisibilityDescription")}
211
+ </p>
212
+ </div>
213
+ <Switch
214
+ checked={Boolean(field.conditional)}
215
+ onCheckedChange={(checked) => {
216
+ if (!checked) {
217
+ update({ conditional: undefined });
218
+ return;
219
+ }
220
+ if (!conditionSource) return;
221
+ update({
222
+ conditional: {
223
+ fieldId: conditionSource.id,
224
+ operator: defaultConditionOperator(conditionSource),
225
+ value: conditionOptions[0] || "",
226
+ },
227
+ });
228
+ }}
229
+ />
230
+ </div>
231
+
232
+ {field.conditional && conditionSource && (
233
+ <div className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
234
+ <div className="space-y-1.5">
235
+ <Label className="text-xs">
236
+ {t("fieldProperties.conditionField")}
237
+ </Label>
238
+ <Select
239
+ value={conditionFieldId}
240
+ onValueChange={(value) => {
241
+ const nextSource = availableFields.find(
242
+ (candidate) => candidate.id === value,
243
+ );
244
+ if (!nextSource) return;
245
+ const nextOptions = conditionValueOptions(nextSource);
246
+ update({
247
+ conditional: {
248
+ fieldId: nextSource.id,
249
+ operator: defaultConditionOperator(nextSource),
250
+ value: nextOptions[0] || "",
251
+ },
252
+ });
253
+ }}
254
+ >
255
+ <SelectTrigger className="h-8 text-xs">
256
+ <SelectValue />
257
+ </SelectTrigger>
258
+ <SelectContent>
259
+ {availableFields.map((candidate) => (
260
+ <SelectItem
261
+ key={candidate.id}
262
+ value={candidate.id}
263
+ className="text-xs"
264
+ >
265
+ {candidate.label || candidate.id}
266
+ </SelectItem>
267
+ ))}
268
+ </SelectContent>
269
+ </Select>
270
+ </div>
271
+
272
+ <div className="space-y-1.5">
273
+ <Label className="text-xs">
274
+ {t("fieldProperties.conditionOperator")}
275
+ </Label>
276
+ <Select
277
+ value={conditionOperator}
278
+ onValueChange={(value) =>
279
+ update({
280
+ conditional: {
281
+ fieldId: conditionFieldId,
282
+ operator: value as ConditionalRule["operator"],
283
+ value: conditionValue,
284
+ },
285
+ })
286
+ }
287
+ >
288
+ <SelectTrigger className="h-8 text-xs">
289
+ <SelectValue />
290
+ </SelectTrigger>
291
+ <SelectContent>
292
+ <SelectItem value="equals" className="text-xs">
293
+ {t("fieldProperties.conditionEquals")}
294
+ </SelectItem>
295
+ <SelectItem value="not_equals" className="text-xs">
296
+ {t("fieldProperties.conditionNotEquals")}
297
+ </SelectItem>
298
+ <SelectItem value="contains" className="text-xs">
299
+ {t("fieldProperties.conditionContains")}
300
+ </SelectItem>
301
+ </SelectContent>
302
+ </Select>
303
+ </div>
304
+
305
+ <div className="space-y-1.5">
306
+ <Label className="text-xs">
307
+ {t("fieldProperties.conditionValue")}
308
+ </Label>
309
+ {conditionOptions.length > 0 ? (
310
+ <Select
311
+ value={conditionValue}
312
+ onValueChange={(value) =>
313
+ update({
314
+ conditional: {
315
+ fieldId: conditionFieldId,
316
+ operator: conditionOperator,
317
+ value,
318
+ },
319
+ })
320
+ }
321
+ >
322
+ <SelectTrigger className="h-8 text-xs">
323
+ <SelectValue />
324
+ </SelectTrigger>
325
+ <SelectContent>
326
+ {conditionOptions.map((option) => (
327
+ <SelectItem
328
+ key={option}
329
+ value={option}
330
+ className="text-xs"
331
+ >
332
+ {option}
333
+ </SelectItem>
334
+ ))}
335
+ </SelectContent>
336
+ </Select>
337
+ ) : (
338
+ <Input
339
+ value={conditionValue}
340
+ onChange={(event) =>
341
+ update({
342
+ conditional: {
343
+ fieldId: conditionFieldId,
344
+ operator: conditionOperator,
345
+ value: event.target.value,
346
+ },
347
+ })
348
+ }
349
+ placeholder={t(
350
+ "fieldProperties.conditionValuePlaceholder",
351
+ )}
352
+ className="h-8 text-xs"
353
+ />
354
+ )}
355
+ </div>
356
+ </div>
357
+ )}
358
+ </div>
359
+ </>
360
+ )}
361
+
164
362
  {/* Options (for select/radio/multiselect) */}
165
363
  {hasOptions.includes(field.type) && (
166
364
  <>
@@ -88,6 +88,16 @@ const messages = {
88
88
  addOption: "Add option",
89
89
  min: "Min",
90
90
  max: "Max",
91
+ conditionalVisibility: "Conditional visibility",
92
+ conditionalVisibilityDescription:
93
+ "Only show this field when an earlier answer matches.",
94
+ conditionField: "Earlier answer",
95
+ conditionOperator: "Condition",
96
+ conditionEquals: "is",
97
+ conditionNotEquals: "is not",
98
+ conditionContains: "contains",
99
+ conditionValue: "Answer",
100
+ conditionValuePlaceholder: "Enter an answer...",
91
101
  fieldTypes: {
92
102
  text: "Short Text",
93
103
  email: "Email",
@@ -144,6 +154,7 @@ const messages = {
144
154
  integrationsTab: "Integrations",
145
155
  settingsSaved: "Settings saved",
146
156
  integrationsSaved: "Integrations saved",
157
+ saveFailed: "Failed to save changes",
147
158
  formTitlePlaceholder: "Form Title",
148
159
  addDescriptionPlaceholder: "Add a description...",
149
160
  dragToReorder: "Drag to reorder",
@@ -210,7 +221,8 @@ const messages = {
210
221
  "Sends a JSON POST with submission data. Works with Zapier, Make, n8n, etc.",
211
222
  googleSheetsBlurb:
212
223
  "Mirror every response into a spreadsheet your team can share.",
213
- googleSheetsHelp: "Deploy an Apps Script web app that receives POST data",
224
+ googleSheetsHelp:
225
+ "Use a deployed Apps Script /exec URL. It must parse JSON from e.postData.contents and append the row.",
214
226
  slackLabel: "Slack",
215
227
  discordLabel: "Discord",
216
228
  webhookLabel: "Webhook",