@agent-native/core 0.80.6 → 0.80.8

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 (71) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +70 -27
  5. package/corpus/core/src/client/analytics.ts +3 -1
  6. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +45 -0
  7. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +42 -4
  8. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +2 -1
  9. package/corpus/templates/analytics/actions/update-dashboard.ts +23 -9
  10. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +72 -1
  11. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  12. package/corpus/templates/analytics/app/i18n-data.ts +34 -0
  13. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +88 -2
  14. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +7 -1
  15. package/corpus/templates/analytics/changelog/2026-06-29-dashboards-can-now-include-extension-panels-that-embed-a-san.md +6 -0
  16. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +69 -16
  17. package/corpus/templates/content/changelog/2026-06-29-read-only-pages-no-longer-try-to-autosave-or-open-editor-onl.md +6 -0
  18. package/corpus/templates/design/AGENTS.md +4 -2
  19. package/corpus/templates/design/actions/edit-design.ts +6 -3
  20. package/corpus/templates/design/actions/generate-screens.ts +5 -3
  21. package/corpus/templates/design/actions/update-design.ts +47 -10
  22. package/corpus/templates/design/actions/update-file.ts +33 -8
  23. package/corpus/templates/design/actions/view-screen.ts +14 -0
  24. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +75 -17
  25. package/corpus/templates/design/app/components/design/EditPanel.tsx +121 -22
  26. package/corpus/templates/design/app/components/design/LayersPanel.tsx +51 -8
  27. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +148 -33
  28. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +11 -11
  29. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +27 -13
  30. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +3 -37
  31. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +18 -2
  32. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +15 -3
  33. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +4 -2
  34. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +18 -4
  35. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +10 -4
  36. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +448 -259
  37. package/corpus/templates/design/app/hooks/use-variant-flow.ts +176 -92
  38. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  39. package/corpus/templates/design/app/i18n-data.ts +10 -0
  40. package/corpus/templates/design/app/pages/DesignEditor.tsx +220 -40
  41. package/corpus/templates/design/changelog/2026-06-29-agent-edits-to-a-design-are-no-longer-occasionally-dropped-o.md +6 -0
  42. package/corpus/templates/design/changelog/2026-06-29-box-shadow-and-gradient-colors-keep-named-css-colors-like-re.md +6 -0
  43. package/corpus/templates/design/changelog/2026-06-29-cut-cmd-ctrl-x-now-removes-the-selected-element-to-the-clipb.md +6 -0
  44. package/corpus/templates/design/changelog/2026-06-29-deleting-with-multiple-layers-selected-in-the-layers-panel-n.md +6 -0
  45. package/corpus/templates/design/changelog/2026-06-29-duplicating-a-screen-now-gives-the-copy-its-own-layer-ids-so.md +6 -0
  46. package/corpus/templates/design/changelog/2026-06-29-hiding-and-re-showing-a-gradient-fill-now-preserves-each-sto.md +6 -0
  47. package/corpus/templates/design/changelog/2026-06-29-picking-a-design-direction-now-submits-to-the-agent-right-aw.md +6 -0
  48. package/corpus/templates/design/changelog/2026-06-29-style-changes-that-set-several-properties-at-once-fixed-size.md +6 -0
  49. package/corpus/templates/design/changelog/2026-06-29-the-editor-no-longer-crashes-when-a-screen-ends-up-with-dupl.md +6 -0
  50. package/corpus/templates/design/e2e/README.md +1 -0
  51. package/corpus/templates/design/e2e/global-setup.ts +13 -1
  52. package/corpus/templates/design/playwright.config.ts +10 -1
  53. package/corpus/templates/design/shared/color-utils.ts +159 -0
  54. package/corpus/templates/design/shared/generation-session.ts +1 -0
  55. package/dist/client/AssistantChat.d.ts.map +1 -1
  56. package/dist/client/AssistantChat.js +54 -24
  57. package/dist/client/AssistantChat.js.map +1 -1
  58. package/dist/client/analytics.d.ts.map +1 -1
  59. package/dist/client/analytics.js +2 -1
  60. package/dist/client/analytics.js.map +1 -1
  61. package/dist/client/extensions/EmbeddedExtension.d.ts +10 -1
  62. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  63. package/dist/client/extensions/EmbeddedExtension.js +34 -3
  64. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  65. package/dist/collab/routes.d.ts +2 -2
  66. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  67. package/dist/notifications/routes.d.ts +2 -2
  68. package/dist/observability/routes.d.ts +5 -5
  69. package/dist/resources/handlers.d.ts +2 -2
  70. package/dist/server/transcribe-voice.d.ts +1 -1
  71. 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: 2041
31
- - template files: 4587
31
+ - template files: 4598
@@ -1,5 +1,17 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.80.8
4
+
5
+ ### Patch Changes
6
+
7
+ - 24deb20: Reduce noisy browser Sentry captures by filtering public-site source-less errors that only report their page URL as a Sentry tag, delaying reconnect aborts until active runs are truly stuck on the server clock, and recovering assistant-ui duplicate message-id append races before they escape.
8
+
9
+ ## 0.80.7
10
+
11
+ ### Patch Changes
12
+
13
+ - 72ef787: Add `onReady` and `onUnavailable` callbacks to `EmbeddedExtension`. `onReady` fires once when the embedded iframe first signals content readiness (its first height report, or iframe load as a fallback) — hosts that gate on content paint, such as dashboard report screenshots, can use it to avoid capturing a blank extension. `onUnavailable` fires when the extension can't be loaded for the current viewer (e.g. 403/404 because it isn't shared with them or no longer exists), so hosts can render an explanatory fallback instead of a silently blank panel.
14
+
3
15
  ## 0.80.6
4
16
 
5
17
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.80.6",
3
+ "version": "0.80.8",
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": {
@@ -228,6 +228,7 @@ function createUserMessageRunConfig(
228
228
 
229
229
  const PENDING_SELECTION_KEY = "pending-selection-context";
230
230
  const ACTIVE_RUN_CLEAR_TIMEOUT_MS = 5_000;
231
+ const ACTIVE_RUN_STUCK_THRESHOLD_MS = 90_000;
231
232
  const ACTIVE_RUN_POLL_INTERVAL_MS = 150;
232
233
  const SUBMIT_ENGINE_STATUS_TIMEOUT_MS = 1000;
233
234
 
@@ -237,15 +238,27 @@ type ActiveRunLookup = {
237
238
  threadId?: string;
238
239
  status?: string;
239
240
  heartbeatAt?: number | null;
241
+ lastProgressAt?: number | null;
242
+ serverNow?: number;
240
243
  };
241
244
 
242
245
  function activeRunLooksStale(runInfo: ActiveRunLookup): boolean {
243
- const heartbeatAt =
244
- typeof runInfo.heartbeatAt === "number" ? runInfo.heartbeatAt : null;
246
+ const lastProgressAt =
247
+ typeof runInfo.lastProgressAt === "number" ? runInfo.lastProgressAt : null;
248
+ const nowMs =
249
+ typeof runInfo.serverNow === "number" ? runInfo.serverNow : Date.now();
245
250
  return (
246
251
  runInfo.status === "running" &&
247
- heartbeatAt != null &&
248
- Date.now() - heartbeatAt > 5000
252
+ lastProgressAt != null &&
253
+ nowMs - lastProgressAt > ACTIVE_RUN_STUCK_THRESHOLD_MS
254
+ );
255
+ }
256
+
257
+ function isAssistantUiDuplicateMessageIdError(error: unknown): boolean {
258
+ const message = error instanceof Error ? error.message : String(error ?? "");
259
+ return (
260
+ message.includes("MessageRepository") &&
261
+ message.includes("same id already exists")
249
262
  );
250
263
  }
251
264
 
@@ -295,14 +308,13 @@ async function waitForThreadRunToClear(apiUrl: string, threadId?: string) {
295
308
  `${apiUrl}/runs/active?threadId=${encodeURIComponent(threadId)}`,
296
309
  );
297
310
  if (res.ok) {
298
- const info = await res.json();
299
- const heartbeatAt =
300
- typeof info?.heartbeatAt === "number" ? info.heartbeatAt : null;
301
- const stale =
302
- info?.status === "running" &&
303
- heartbeatAt != null &&
304
- Date.now() - heartbeatAt > 5000;
305
- if (!info?.active || info?.status !== "running" || stale) return;
311
+ const info = (await res.json()) as ActiveRunLookup;
312
+ if (
313
+ !info?.active ||
314
+ info?.status !== "running" ||
315
+ activeRunLooksStale(info)
316
+ )
317
+ return;
306
318
  }
307
319
  } catch {
308
320
  // Transient poll failure — try again until the short grace period ends.
@@ -1467,9 +1479,40 @@ const AssistantChatInner = forwardRef<
1467
1479
  }
1468
1480
  }, [apiUrl, importThreadData, loadHistoryRepository, threadId]);
1469
1481
 
1482
+ const exportCleanThreadRepo = useCallback(
1483
+ () =>
1484
+ ensureMessageMetadata(normalizeThreadRepository(threadRuntime.export())),
1485
+ [threadRuntime],
1486
+ );
1487
+
1488
+ const appendThreadMessage = useCallback(
1489
+ (message: Parameters<typeof threadRuntime.append>[0]) => {
1490
+ try {
1491
+ threadRuntime.append(message);
1492
+ return;
1493
+ } catch (error) {
1494
+ if (!isAssistantUiDuplicateMessageIdError(error)) throw error;
1495
+ }
1496
+
1497
+ try {
1498
+ threadRuntime.import(exportCleanThreadRepo());
1499
+ } catch {
1500
+ // Best effort cleanup; retry below handles the still-duplicated case.
1501
+ }
1502
+
1503
+ try {
1504
+ threadRuntime.append(message);
1505
+ } catch (retryError) {
1506
+ if (isAssistantUiDuplicateMessageIdError(retryError)) return;
1507
+ throw retryError;
1508
+ }
1509
+ },
1510
+ [exportCleanThreadRepo, threadRuntime],
1511
+ );
1512
+
1470
1513
  const cacheCurrentThreadSnapshot = useCallback(() => {
1471
1514
  if (!threadId || messages.length === 0) return;
1472
- const repo = threadRuntime.export();
1515
+ const repo = exportCleanThreadRepo();
1473
1516
  const threadData = JSON.stringify(stripBase64FromRepo(repo));
1474
1517
  const { title, preview } = extractThreadMeta(repo);
1475
1518
  writeCachedThreadSnapshot(apiUrl, threadId, {
@@ -1478,7 +1521,7 @@ const AssistantChatInner = forwardRef<
1478
1521
  preview,
1479
1522
  messageCount: messages.length,
1480
1523
  });
1481
- }, [apiUrl, messages.length, threadId, threadRuntime]);
1524
+ }, [apiUrl, exportCleanThreadRepo, messages.length, threadId]);
1482
1525
 
1483
1526
  useBrowserLayoutEffect(() => {
1484
1527
  if (hasImportedInitialCachedSnapshotRef.current) return;
@@ -1975,7 +2018,7 @@ const AssistantChatInner = forwardRef<
1975
2018
  const timeSinceLastSave = now - lastSaveTimeRef.current;
1976
2019
  if (timeSinceLastSave < 5000) return;
1977
2020
 
1978
- const repo = threadRuntime.export();
2021
+ const repo = exportCleanThreadRepo();
1979
2022
  const { title, preview } = extractThreadMeta(repo);
1980
2023
  const threadData = JSON.stringify(stripBase64FromRepo(repo));
1981
2024
  const snapshot = {
@@ -1989,7 +2032,7 @@ const AssistantChatInner = forwardRef<
1989
2032
  savedTitleRef.current = title;
1990
2033
  writeCachedThreadSnapshot(apiUrl, threadId, snapshot);
1991
2034
  onSaveThreadRef.current(threadId, snapshot);
1992
- }, [apiUrl, messages, isRunning, threadId, threadRuntime]);
2035
+ }, [apiUrl, exportCleanThreadRepo, messages, isRunning, threadId]);
1993
2036
 
1994
2037
  // Persist full thread data after each completed response
1995
2038
  useEffect(() => {
@@ -1997,7 +2040,7 @@ const AssistantChatInner = forwardRef<
1997
2040
  if (isRunning) return;
1998
2041
  if (messages.length === 0) return;
1999
2042
 
2000
- const repo = threadRuntime.export();
2043
+ const repo = exportCleanThreadRepo();
2001
2044
 
2002
2045
  if (threadId && onSaveThreadRef.current) {
2003
2046
  // Save to server via the hook callback
@@ -2019,7 +2062,7 @@ const AssistantChatInner = forwardRef<
2019
2062
  sessionStorage.setItem(storageKey, JSON.stringify(repo));
2020
2063
  } catch {}
2021
2064
  }
2022
- }, [apiUrl, messages, isRunning, threadId, tabId, threadRuntime]);
2065
+ }, [apiUrl, exportCleanThreadRepo, messages, isRunning, threadId, tabId]);
2023
2066
 
2024
2067
  useEffect(() => {
2025
2068
  onMessageCountChange?.(messages.length);
@@ -2280,7 +2323,7 @@ const AssistantChatInner = forwardRef<
2280
2323
  next.attachments && next.attachments.length > 0
2281
2324
  ? next.attachments
2282
2325
  : (imageAttachments ?? []);
2283
- threadRuntime.append({
2326
+ appendThreadMessage({
2284
2327
  role: "user",
2285
2328
  content: [{ type: "text", text: next.text }],
2286
2329
  ...(messageAttachments.length > 0
@@ -2334,12 +2377,12 @@ const AssistantChatInner = forwardRef<
2334
2377
  };
2335
2378
  }, [
2336
2379
  apiUrl,
2380
+ appendThreadMessage,
2337
2381
  applyLocalQueuedMessages,
2338
2382
  isRestoring,
2339
2383
  isRunning,
2340
2384
  queuedMessages,
2341
2385
  threadId,
2342
- threadRuntime,
2343
2386
  ]);
2344
2387
 
2345
2388
  // Clear frozen reconnect content + forceStopped only on the false→true
@@ -2683,7 +2726,7 @@ const AssistantChatInner = forwardRef<
2683
2726
  },
2684
2727
  ]);
2685
2728
  } else {
2686
- threadRuntime.append({
2729
+ appendThreadMessage({
2687
2730
  role: "user",
2688
2731
  content: [{ type: "text", text: submittedText }],
2689
2732
  ...(messageAttachments.length > 0
@@ -2708,7 +2751,7 @@ const AssistantChatInner = forwardRef<
2708
2751
  execMode,
2709
2752
  isRunning,
2710
2753
  materializeFrozenReconnectContent,
2711
- threadRuntime,
2754
+ appendThreadMessage,
2712
2755
  updateComposerContextItems,
2713
2756
  ],
2714
2757
  );
@@ -2774,7 +2817,7 @@ const AssistantChatInner = forwardRef<
2774
2817
  },
2775
2818
  exportThreadSnapshot() {
2776
2819
  if (messages.length === 0) return null;
2777
- const repo = threadRuntime.export();
2820
+ const repo = exportCleanThreadRepo();
2778
2821
  const { title, preview } = extractThreadMeta(repo);
2779
2822
  return {
2780
2823
  threadData: JSON.stringify(repo),
@@ -2786,10 +2829,10 @@ const AssistantChatInner = forwardRef<
2786
2829
  }),
2787
2830
  [
2788
2831
  addToQueue,
2832
+ exportCleanThreadRepo,
2789
2833
  messages.length,
2790
2834
  stageComposerContextItem,
2791
2835
  thread.isRunning,
2792
- threadRuntime,
2793
2836
  ],
2794
2837
  );
2795
2838
 
@@ -2986,7 +3029,7 @@ const AssistantChatInner = forwardRef<
2986
3029
  const approvalCtx = useMemo<ApprovalContextValue>(
2987
3030
  () => ({
2988
3031
  onApprove: (approvalKey: string) => {
2989
- threadRuntime.append({
3032
+ appendThreadMessage({
2990
3033
  role: "user",
2991
3034
  content: [
2992
3035
  {
@@ -3008,7 +3051,7 @@ const AssistantChatInner = forwardRef<
3008
3051
  } as Parameters<typeof threadRuntime.append>[0]);
3009
3052
  },
3010
3053
  }),
3011
- [threadRuntime, execMode],
3054
+ [appendThreadMessage, execMode],
3012
3055
  );
3013
3056
 
3014
3057
  return (
@@ -3201,7 +3244,7 @@ const AssistantChatInner = forwardRef<
3201
3244
  setMissingKeyBouncePulse((p) => p + 1);
3202
3245
  return;
3203
3246
  }
3204
- threadRuntime.append({
3247
+ appendThreadMessage({
3205
3248
  role: "user",
3206
3249
  content: [{ type: "text", text: suggestion }],
3207
3250
  });
@@ -610,7 +610,9 @@ function isAgentNativeDocsUrl(url: string): boolean {
610
610
 
611
611
  function shouldDropBrowserSentryNoise(event: Sentry.Event): boolean {
612
612
  const exceptionValues = event.exception?.values ?? [];
613
- const requestUrl = event.request?.url?.toLowerCase() ?? "";
613
+ const taggedUrl =
614
+ typeof event.tags?.url === "string" ? event.tags.url : undefined;
615
+ const requestUrl = (event.request?.url ?? taggedUrl ?? "").toLowerCase();
614
616
  const isDocsPage = isAgentNativeDocsUrl(requestUrl);
615
617
  // AgentAutoContinueSignal is a control-flow sentinel thrown to bubble
616
618
  // out of the SSE stream parser when the agent run needs to be
@@ -29,6 +29,7 @@ import {
29
29
  } from "./delete-extension.js";
30
30
  import {
31
31
  extensionLoadError,
32
+ extensionLoadErrorStatus,
32
33
  shouldRetryExtensionLoad,
33
34
  } from "./extension-load-error.js";
34
35
  import {
@@ -74,6 +75,15 @@ export interface EmbeddedExtensionProps {
74
75
  className?: string;
75
76
  /** Initial iframe height before content reports a real height. */
76
77
  initialHeight?: number;
78
+ /** Fires once when the embedded iframe first signals content readiness — its
79
+ * first height report, or iframe load as a fallback. Hosts that gate on
80
+ * content paint (e.g. dashboard report screenshots) use this. */
81
+ onReady?: () => void;
82
+ /** Fires when the extension can't be loaded for this viewer (e.g. 403/404 —
83
+ * the extension isn't shared with them or no longer exists). Hosts can use
84
+ * this to render an explanatory fallback instead of a blank panel. By default
85
+ * the component renders nothing on failure (slot-style silent skip). */
86
+ onUnavailable?: (status?: number) => void;
77
87
  }
78
88
 
79
89
  /**
@@ -87,8 +97,20 @@ export function EmbeddedExtension({
87
97
  context,
88
98
  className,
89
99
  initialHeight = 80,
100
+ onReady,
101
+ onUnavailable,
90
102
  }: EmbeddedExtensionProps) {
91
103
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
104
+ // Latch the readiness signal so onReady fires at most once per iframe
105
+ // instance. Reset when the iframe is recreated (extensionId/updatedAt change).
106
+ const onReadyRef = useRef(onReady);
107
+ onReadyRef.current = onReady;
108
+ const readyFiredRef = useRef(false);
109
+ const fireReady = () => {
110
+ if (readyFiredRef.current) return;
111
+ readyFiredRef.current = true;
112
+ onReadyRef.current?.();
113
+ };
92
114
  const [height, setHeight] = useState<number>(initialHeight);
93
115
  const [isDark, setIsDark] = useState(false);
94
116
  // (audit H4) Mirror ExtensionViewer's role-aware gating; deny-by-default until
@@ -120,6 +142,8 @@ export function EmbeddedExtension({
120
142
  data: extension,
121
143
  isFetching,
122
144
  isLoading,
145
+ isError,
146
+ error,
123
147
  } = useQuery<Extension>({
124
148
  queryKey: ["extension", extensionId],
125
149
  queryFn: async () => {
@@ -141,6 +165,21 @@ export function EmbeddedExtension({
141
165
  retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 4000),
142
166
  });
143
167
 
168
+ // Notify the host once when the extension can't be loaded for this viewer so
169
+ // it can show a fallback instead of a blank panel.
170
+ const onUnavailableRef = useRef(onUnavailable);
171
+ onUnavailableRef.current = onUnavailable;
172
+ const unavailableFiredRef = useRef(false);
173
+ useEffect(() => {
174
+ unavailableFiredRef.current = false;
175
+ }, [extensionId]);
176
+ useEffect(() => {
177
+ if (isError && !isFetching && !unavailableFiredRef.current) {
178
+ unavailableFiredRef.current = true;
179
+ onUnavailableRef.current?.(extensionLoadErrorStatus(error));
180
+ }
181
+ }, [isError, isFetching, error]);
182
+
144
183
  // Initial dark state is baked into the URL on first load only; subsequent
145
184
  // theme toggles update the iframe's <html class="dark"> via postMessage so
146
185
  // the user's interaction state inside the extension survives the toggle.
@@ -158,6 +197,7 @@ export function EmbeddedExtension({
158
197
  useEffect(() => {
159
198
  bridgeContextRef.current = { role: "viewer", isAuthor: false };
160
199
  bindingLatchedRef.current = false;
200
+ readyFiredRef.current = false;
161
201
  }, [extensionId, extension?.updatedAt]);
162
202
 
163
203
  useEffect(() => {
@@ -215,6 +255,8 @@ export function EmbeddedExtension({
215
255
  const h = Number(message.height);
216
256
  if (Number.isFinite(h) && h > 0) {
217
257
  setHeight(Math.ceil(h));
258
+ // First laid-out height means the content has painted.
259
+ fireReady();
218
260
  }
219
261
  return;
220
262
  }
@@ -330,6 +372,9 @@ export function EmbeddedExtension({
330
372
  { type: "agent-native-slot-context", context: context ?? {} },
331
373
  "*",
332
374
  );
375
+ // Fallback readiness signal in case the extension never reports a
376
+ // height (e.g. fixed-height content that skips auto-resize).
377
+ fireReady();
333
378
  }}
334
379
  />
335
380
  <EmbeddedToolMenu
@@ -56,7 +56,7 @@ When the user asks for a dashboard:
56
56
  2. If a metric definition, date range, or grain is ambiguous and the choice would change the panel's numbers, use the `ask-question` clarifying tool once before building. Skip it when the dictionary or the user already settled it.
57
57
  3. If a metric is not documented, do not guess column names. Ask for the table/columns or introspect the provider schema, then propose a dictionary entry with `save-data-dictionary-entry`.
58
58
  4. Build a complete `SqlDashboardConfig` with `name` and `panels`. Optionally set top-level `columns` (1–6, default 2) to control how many grid columns the panels before any section use.
59
- 5. Every panel needs `id`, `title`, `source`, `chartType`, `width`, and `sql`. `width` is the number of grid columns the panel spans (1..6, clamped to the active section's column count). Section panels skip `source` and `sql` and may set their own `columns` (1–6) to override the dashboard default for the panels following the section.
59
+ 5. Every panel needs `id`, `title`, `source`, `chartType`, `width`, and `sql`. `width` is the number of grid columns the panel spans (1..6, clamped to the active section's column count). Section panels skip `source` and `sql` and may set their own `columns` (1–6) to override the dashboard default for the panels following the section. Extension panels (`chartType: "extension"`) also skip `source` and `sql`; instead they require `config.extensionId` (see "Embedding An Extension As A Panel").
60
60
  6. Persist with `update-dashboard`, not raw SQL or settings writes.
61
61
  7. Navigate to it with `pnpm action navigate --view=adhoc --dashboardId=<id>`.
62
62
 
@@ -85,6 +85,37 @@ production mode, call `create-extension` automatically and then tell the user
85
85
  that the request needed a bespoke surface, so you built it as an extension
86
86
  rather than forcing it into a native dashboard config.
87
87
 
88
+ ## Embedding An Extension As A Panel
89
+
90
+ Use `chartType: "extension"` to embed an existing extension as a dashboard
91
+ panel. This is different from the section above: there you replace the whole
92
+ dashboard with an extension; here you drop a single extension widget into one
93
+ panel slot alongside normal SQL charts. The panel renders the extension's
94
+ sandboxed iframe instead of running a query, so it skips `source` and `sql` and
95
+ instead requires `config.extensionId` (the id of an extension that already
96
+ exists — create it first with `create-extension`). Validation rejects an
97
+ extension panel without a non-empty `config.extensionId`.
98
+
99
+ ```jsonc
100
+ {
101
+ "id": "pipeline-widget",
102
+ "title": "Pipeline Widget",
103
+ "chartType": "extension",
104
+ "width": 3,
105
+ "config": { "extensionId": "<existing-extension-id>" },
106
+ }
107
+ ```
108
+
109
+ Notes:
110
+
111
+ - The panel renders full-bleed (no card chrome/title) and does not receive the
112
+ dashboard's filters/variables/date range — it's a standalone widget for now.
113
+ - Access is scoped per viewer: embedding does NOT grant access to the extension
114
+ (same model as ExtensionSlots). If you share a dashboard more broadly than the
115
+ embedded extension, viewers without access to that extension see an
116
+ "extension unavailable" message instead of the content. Share the extension to
117
+ the same audience as the dashboard so all viewers can see it.
118
+
88
119
  ## Config Shape
89
120
 
90
121
  ```jsonc
@@ -207,7 +238,7 @@ type PanelPatch = {
207
238
  title?: string;
208
239
  sql?: string;
209
240
  source?: "bigquery" | "ga4" | "amplitude" | "first-party" | "demo" | "prometheus";
210
- chartType?: "line" | "area" | "bar" | "metric" | "table" | "pie" | "section" | "heatmap" | "callout";
241
+ chartType?: "line" | "area" | "bar" | "metric" | "table" | "pie" | "section" | "heatmap" | "callout" | "extension";
211
242
  width?: number;
212
243
  columns?: number;
213
244
  tab?: string;
@@ -219,8 +250,9 @@ type PanelInput = PanelPatch & {
219
250
  id: string;
220
251
  title: string;
221
252
  chartType: NonNullable<PanelPatch["chartType"]>;
222
- source?: PanelPatch["source"]; // required for non-section panels
223
- sql?: string; // required for non-section panels
253
+ source?: PanelPatch["source"]; // required for non-section / non-extension panels
254
+ sql?: string; // required for non-section / non-extension panels
255
+ // For chartType "extension": config.extensionId is required (the extension to embed).
224
256
  };
225
257
 
226
258
  type PanelFilter = {
@@ -450,6 +482,12 @@ pnpm action set-resource-visibility --resourceType dashboard --resourceId weekly
450
482
 
451
483
  Writes require editor access; deletes require admin access. Owners always satisfy access checks.
452
484
 
485
+ If a dashboard embeds an extension panel (`chartType: "extension"`), sharing the
486
+ dashboard does not share the extension. Share the referenced extension to the
487
+ same audience (`share-resource --resourceType extension ...`) so all dashboard
488
+ viewers can see the embedded content; otherwise they get an "extension
489
+ unavailable" placeholder.
490
+
453
491
  ## Important Rules
454
492
 
455
493
  - Never fabricate data or create a dashboard from guessed schema. A panel's SQL must hit a real source; do not present figures you did not actually query.
@@ -36,7 +36,7 @@ type PanelPatch = {
36
36
  title?: string;
37
37
  sql?: string;
38
38
  source?: "bigquery" | "ga4" | "amplitude" | "first-party" | "demo" | "prometheus";
39
- chartType?: "line" | "area" | "bar" | "metric" | "table" | "pie" | "section" | "heatmap" | "callout";
39
+ chartType?: "line" | "area" | "bar" | "metric" | "table" | "pie" | "section" | "heatmap" | "callout" | "extension";
40
40
  width?: number;
41
41
  columns?: number;
42
42
  tab?: string;
@@ -115,6 +115,7 @@ export const DASHBOARD_MUTATION_EXAMPLES = [
115
115
  'dashboard.insertPanel({"id":"new-kpi","title":"New KPI","source":"first-party","chartType":"metric","width":1,"sql":"SELECT COUNT(*) AS value FROM analytics_events"}).atTop();',
116
116
  'dashboard.insertPanel({"id":"new-chart","title":"New Chart","source":"first-party","chartType":"line","width":1,"sql":"SELECT date, COUNT(*) AS value FROM analytics_events GROUP BY date ORDER BY date"}).nextTo("retention-over-time");',
117
117
  'dashboard.insertPanel({"id":"row-chart","title":"Row Chart","source":"first-party","chartType":"bar","width":1,"sql":"SELECT name, COUNT(*) AS value FROM analytics_events GROUP BY name"}).atRow(2);',
118
+ 'dashboard.insertPanel({"id":"pipeline-widget","title":"Pipeline Widget","chartType":"extension","width":3,"config":{"extensionId":"<existing-extension-id>"}}).atBottom();',
118
119
  ] as const;
119
120
 
120
121
  export type DashboardMutationOperation =
@@ -334,12 +334,15 @@ export function validateDashboardConfig(
334
334
  if (!p || typeof p !== "object") {
335
335
  return `panel[${i}] must be an object`;
336
336
  }
337
- // Section panels are pure layout dividers, so source and sql are optional.
338
- // Width stays required for backward-compatible dashboard payloads.
337
+ // Section panels are pure layout dividers and extension panels render their
338
+ // own iframe, so both make source and sql optional. Width stays required for
339
+ // backward-compatible dashboard payloads.
339
340
  const isSection = p.chartType === "section";
340
- const required = isSection
341
- ? (["id", "title", "chartType", "width"] as const)
342
- : (["id", "title", "sql", "source", "chartType", "width"] as const);
341
+ const isExtension = p.chartType === "extension";
342
+ const required =
343
+ isSection || isExtension
344
+ ? (["id", "title", "chartType", "width"] as const)
345
+ : (["id", "title", "sql", "source", "chartType", "width"] as const);
343
346
  for (const field of required) {
344
347
  const v = p[field];
345
348
  if (field === "width") {
@@ -352,9 +355,19 @@ export function validateDashboardConfig(
352
355
  return `panel[${i}].${field} is required (non-empty string)`;
353
356
  }
354
357
  }
355
- if (!isSection && !validSources.has(p.source as string)) {
358
+ if (!isSection && !isExtension && !validSources.has(p.source as string)) {
356
359
  return `panel[${i}].source must be 'bigquery', 'ga4', 'amplitude', 'first-party', 'demo', or 'prometheus' (got '${p.source}'). source selects the backend — put the PromQL/SQL/table name in sql, not here.`;
357
360
  }
361
+ if (isExtension) {
362
+ const cfg = p.config as Record<string, unknown> | undefined;
363
+ const extensionId =
364
+ cfg && typeof cfg.extensionId === "string"
365
+ ? cfg.extensionId.trim()
366
+ : "";
367
+ if (!extensionId) {
368
+ return `panel[${i}].config.extensionId is required for extension panels (the id of the extension to render inline)`;
369
+ }
370
+ }
358
371
  if (
359
372
  isSection &&
360
373
  p.columns !== undefined &&
@@ -382,9 +395,10 @@ export async function validatePanelSql(
382
395
  const vars = buildDryRunVars(config);
383
396
  for (let i = 0; i < panels.length; i++) {
384
397
  const p = panels[i] as Record<string, unknown>;
385
- // Sections are layout-only no SQL to dry-run. heatmap, callout, and other
386
- // query panels still validate normally below.
387
- if (p.chartType === "section") continue;
398
+ // Sections are layout-only and extensions render their own iframe neither
399
+ // has SQL to dry-run. heatmap, callout, and other query panels still
400
+ // validate normally below.
401
+ if (p.chartType === "section" || p.chartType === "extension") continue;
388
402
  if (p.source === "amplitude") {
389
403
  const raw = typeof p.sql === "string" ? p.sql : "";
390
404
  if (raw.trim()) {
@@ -1,4 +1,5 @@
1
1
  import { useT } from "@agent-native/core/client";
2
+ import { EmbeddedExtension } from "@agent-native/core/client/extensions";
2
3
  import {
3
4
  IconArrowsSort,
4
5
  IconSortAscending,
@@ -868,7 +869,10 @@ export function SqlChart({
868
869
  const t = useT();
869
870
  // Hooks must be called unconditionally before any early return.
870
871
  const isSection = panel.chartType === "section";
871
- const shouldQuery = !isSection && loadData;
872
+ const isExtension = panel.chartType === "extension";
873
+ // Sections are pure layout and extensions render their own iframe — neither
874
+ // runs the SQL pipeline.
875
+ const shouldQuery = !isSection && !isExtension && loadData;
872
876
  const sql = serializePanelSql(resolvedSql ?? panel.sql);
873
877
  const {
874
878
  data: result,
@@ -918,6 +922,24 @@ export function SqlChart({
918
922
  </div>
919
923
  );
920
924
  }
925
+
926
+ // Extension panels render a sandboxed extension iframe instead of querying a
927
+ // data source. The extension id lives in config.extensionId.
928
+ if (isExtension) {
929
+ const extensionId = panel.config?.extensionId;
930
+ if (!extensionId) {
931
+ return (
932
+ <div className="flex flex-1 items-center justify-center px-4 py-8 min-h-[120px]">
933
+ <p className="text-sm text-muted-foreground text-center">
934
+ {t("sqlDashboard.extensionMissingId")}
935
+ </p>
936
+ </div>
937
+ );
938
+ }
939
+ return (
940
+ <DashboardExtensionPanel extensionId={extensionId} panelId={panel.id} />
941
+ );
942
+ }
921
943
  const colors = panel.config?.colors || DEFAULT_COLORS;
922
944
  const yFormatter = panel.config?.yFormatter;
923
945
 
@@ -1041,6 +1063,55 @@ export function SqlChart({
1041
1063
  );
1042
1064
  }
1043
1065
 
1066
+ function DashboardExtensionPanel({
1067
+ extensionId,
1068
+ panelId,
1069
+ }: {
1070
+ extensionId: string;
1071
+ panelId: string;
1072
+ }) {
1073
+ const t = useT();
1074
+ // Hold the report-readiness marker until the extension iframe paints so
1075
+ // dashboard report screenshots don't capture a blank extension panel.
1076
+ const [ready, setReady] = useState(false);
1077
+ const [unavailable, setUnavailable] = useState(false);
1078
+
1079
+ // Embedding never grants access to the extension itself (same model as
1080
+ // ExtensionSlots). A viewer with dashboard-only access who can't see the
1081
+ // referenced extension gets a clear message instead of a blank panel.
1082
+ if (unavailable) {
1083
+ return (
1084
+ <div className="flex flex-1 items-center justify-center px-4 py-8 min-h-[120px]">
1085
+ <p className="text-sm text-muted-foreground text-center">
1086
+ {t("sqlDashboard.extensionUnavailable")}
1087
+ </p>
1088
+ </div>
1089
+ );
1090
+ }
1091
+
1092
+ return (
1093
+ <div
1094
+ className="w-full"
1095
+ data-dashboard-report-loading={ready ? undefined : "true"}
1096
+ >
1097
+ <EmbeddedExtension
1098
+ extensionId={extensionId}
1099
+ slotId={`dashboard-panel-${panelId}`}
1100
+ className="w-full"
1101
+ // Intentional for v1: extension panels are standalone widgets and do not
1102
+ // receive the dashboard's filters/variables/date range as `context`.
1103
+ initialHeight={180}
1104
+ onReady={() => setReady(true)}
1105
+ onUnavailable={() => {
1106
+ // Clear the report-loading gate so report capture doesn't hang.
1107
+ setReady(true);
1108
+ setUnavailable(true);
1109
+ }}
1110
+ />
1111
+ </div>
1112
+ );
1113
+ }
1114
+
1044
1115
  function MetricRenderer({
1045
1116
  rows,
1046
1117
  panel,
@@ -629,6 +629,8 @@ const messages = {
629
629
  off: "Off",
630
630
  since: "Since",
631
631
  sectionOptions: "部分選項",
632
+ extensionMissingId: "此擴充面板未選擇任何擴充功能。",
633
+ extensionUnavailable: "此擴充功能未與您共用,或已不存在。",
632
634
  panelOptions: "面板選項",
633
635
  fullScreen: "全螢幕",
634
636
  refresh: "重新整理",