@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AssistantChat.tsx +70 -27
- package/corpus/core/src/client/analytics.ts +3 -1
- package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +45 -0
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +42 -4
- package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +2 -1
- package/corpus/templates/analytics/actions/update-dashboard.ts +23 -9
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +72 -1
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/analytics/app/i18n-data.ts +34 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +88 -2
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +7 -1
- package/corpus/templates/analytics/changelog/2026-06-29-dashboards-can-now-include-extension-panels-that-embed-a-san.md +6 -0
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +69 -16
- package/corpus/templates/content/changelog/2026-06-29-read-only-pages-no-longer-try-to-autosave-or-open-editor-onl.md +6 -0
- package/corpus/templates/design/AGENTS.md +4 -2
- package/corpus/templates/design/actions/edit-design.ts +6 -3
- package/corpus/templates/design/actions/generate-screens.ts +5 -3
- package/corpus/templates/design/actions/update-design.ts +47 -10
- package/corpus/templates/design/actions/update-file.ts +33 -8
- package/corpus/templates/design/actions/view-screen.ts +14 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +75 -17
- package/corpus/templates/design/app/components/design/EditPanel.tsx +121 -22
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +51 -8
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +148 -33
- package/corpus/templates/design/app/components/design/QuestionFlow.tsx +11 -11
- package/corpus/templates/design/app/components/design/TweaksPanel.tsx +27 -13
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +3 -37
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +18 -2
- package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +15 -3
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +4 -2
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +18 -4
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +10 -4
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +448 -259
- package/corpus/templates/design/app/hooks/use-variant-flow.ts +176 -92
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +10 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +220 -40
- package/corpus/templates/design/changelog/2026-06-29-agent-edits-to-a-design-are-no-longer-occasionally-dropped-o.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-box-shadow-and-gradient-colors-keep-named-css-colors-like-re.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-cut-cmd-ctrl-x-now-removes-the-selected-element-to-the-clipb.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-deleting-with-multiple-layers-selected-in-the-layers-panel-n.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-duplicating-a-screen-now-gives-the-copy-its-own-layer-ids-so.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-hiding-and-re-showing-a-gradient-fill-now-preserves-each-sto.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-picking-a-design-direction-now-submits-to-the-agent-right-aw.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-style-changes-that-set-several-properties-at-once-fixed-size.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-editor-no-longer-crashes-when-a-screen-ends-up-with-dupl.md +6 -0
- package/corpus/templates/design/e2e/README.md +1 -0
- package/corpus/templates/design/e2e/global-setup.ts +13 -1
- package/corpus/templates/design/playwright.config.ts +10 -1
- package/corpus/templates/design/shared/color-utils.ts +159 -0
- package/corpus/templates/design/shared/generation-session.ts +1 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +54 -24
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +2 -1
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.d.ts +10 -1
- package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.js +34 -3
- package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +5 -5
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -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
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.80.
|
|
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
|
|
244
|
-
typeof runInfo.
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
info
|
|
303
|
-
|
|
304
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
|
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
|
|
338
|
-
// Width stays required for
|
|
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
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
386
|
-
// query panels still
|
|
387
|
-
|
|
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
|
|
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,
|