@agent-native/core 0.84.17 → 0.84.20
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 +19 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/run-store.ts +180 -13
- package/corpus/core/src/client/AssistantChat.tsx +49 -0
- package/corpus/core/src/client/active-run-state.ts +30 -0
- package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +41 -3
- package/corpus/core/src/client/extensions/ExtensionViewer.tsx +133 -0
- package/corpus/core/src/client/extensions/ExtensionsSidebarSection.tsx +2 -2
- package/corpus/core/src/client/session-replay.ts +2 -2
- package/corpus/core/src/extensions/html-shell.ts +17 -3
- package/corpus/core/src/extensions/theme.ts +39 -0
- package/corpus/templates/analytics/actions/provider-api-request.ts +8 -1
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +2 -1
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +91 -33
- package/corpus/templates/analytics/changelog/2026-07-01-session-replay-recordings-upload-reliably-in-production.md +6 -0
- package/corpus/templates/analytics/server/handlers/session-replay.ts +46 -5
- package/corpus/templates/calendar/app/components/booking/TimeSlotPicker.tsx +10 -0
- package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +15 -6
- package/corpus/templates/calendar/app/components/calendar/GoogleSetupWizard.tsx +22 -0
- package/corpus/templates/calendar/app/hooks/use-bookings.ts +6 -2
- package/corpus/templates/calendar/app/i18n/zh-TW.ts +4 -0
- package/corpus/templates/calendar/app/i18n-data.ts +107 -0
- package/corpus/templates/calendar/app/lib/google-oauth-setup.ts +17 -0
- package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +9 -1
- package/corpus/templates/calendar/app/pages/BookingPage.tsx +40 -26
- package/corpus/templates/calendar/app/pages/Settings.tsx +3 -1
- package/corpus/templates/calendar/changelog/2026-07-01-booking-links-now-show-an-error-when-calendar-availability-c.md +6 -0
- package/corpus/templates/calendar/server/handlers/bookings.ts +78 -13
- package/corpus/templates/calendar/server/handlers/google-auth.ts +3 -2
- package/corpus/templates/calendar/server/lib/google-calendar.ts +16 -2
- package/corpus/templates/clips/changelog/2026-07-01-chrome-recording-uploads-now-serialize-chunks-to-avoid-resum.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +30 -23
- package/corpus/templates/design/app/lib/design-system-preview.ts +69 -0
- package/corpus/templates/design/app/pages/DesignSystems.tsx +71 -50
- package/corpus/templates/design/changelog/2026-07-01-design-systems-no-longer-crash-on-responsive-tokens.md +6 -0
- package/dist/agent/run-store.d.ts +19 -6
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +163 -13
- package/dist/agent/run-store.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +2 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +36 -2
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/active-run-state.d.ts +3 -0
- package/dist/client/active-run-state.d.ts.map +1 -1
- package/dist/client/active-run-state.js +24 -0
- package/dist/client/active-run-state.js.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.js +32 -3
- package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
- package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
- package/dist/client/extensions/ExtensionViewer.js +59 -2
- package/dist/client/extensions/ExtensionViewer.js.map +1 -1
- package/dist/client/extensions/ExtensionsSidebarSection.js +2 -2
- package/dist/client/extensions/ExtensionsSidebarSection.js.map +1 -1
- package/dist/client/session-replay.js +2 -2
- package/dist/client/session-replay.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/extensions/html-shell.d.ts.map +1 -1
- package/dist/extensions/html-shell.js +17 -3
- package/dist/extensions/html-shell.js.map +1 -1
- package/dist/extensions/theme.d.ts +9 -0
- package/dist/extensions/theme.d.ts.map +1 -1
- package/dist/extensions/theme.js +38 -0
- package/dist/extensions/theme.js.map +1 -1
- 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 +8 -8
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
IconArrowLeft,
|
|
3
3
|
IconArrowBackUp,
|
|
4
4
|
IconChevronRight,
|
|
5
|
+
IconCode,
|
|
5
6
|
IconDotsVertical,
|
|
6
7
|
IconHistory,
|
|
7
8
|
IconLoader2,
|
|
@@ -20,6 +21,7 @@ import { getThemeVars } from "../../extensions/theme.js";
|
|
|
20
21
|
import { sendToAgentChat } from "../agent-chat.js";
|
|
21
22
|
import { AgentToggleButton } from "../AgentPanel.js";
|
|
22
23
|
import { agentNativePath, appPath } from "../api-path.js";
|
|
24
|
+
import { Dialog, DialogContent, DialogTitle } from "../components/ui/dialog.js";
|
|
23
25
|
import {
|
|
24
26
|
Popover,
|
|
25
27
|
PopoverContent,
|
|
@@ -324,6 +326,131 @@ function applyCanonicalLink(path: string): () => void {
|
|
|
324
326
|
};
|
|
325
327
|
}
|
|
326
328
|
|
|
329
|
+
function SourceCodeDialog({
|
|
330
|
+
extension,
|
|
331
|
+
onSaved,
|
|
332
|
+
}: {
|
|
333
|
+
extension: Extension;
|
|
334
|
+
onSaved?: () => void;
|
|
335
|
+
}) {
|
|
336
|
+
const queryClient = useQueryClient();
|
|
337
|
+
const [open, setOpen] = useState(false);
|
|
338
|
+
const [code, setCode] = useState(extension.content ?? "");
|
|
339
|
+
const [saving, setSaving] = useState(false);
|
|
340
|
+
const [error, setError] = useState<string | null>(null);
|
|
341
|
+
|
|
342
|
+
// Sync in when the dialog opens, or when the viewed extension changes
|
|
343
|
+
// while the dialog stays mounted (e.g. re-parented to a different id).
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
if (open) setCode(extension.content ?? "");
|
|
346
|
+
}, [open, extension.id]);
|
|
347
|
+
|
|
348
|
+
const isDirty = code !== (extension.content ?? "");
|
|
349
|
+
|
|
350
|
+
// Block Escape / outside-click from closing while there are unsaved edits.
|
|
351
|
+
const handleOpenChange = (next: boolean) => {
|
|
352
|
+
if (!next && isDirty) return;
|
|
353
|
+
setOpen(next);
|
|
354
|
+
if (!next) setError(null);
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const handleCancel = () => {
|
|
358
|
+
setCode(extension.content ?? "");
|
|
359
|
+
setOpen(false);
|
|
360
|
+
setError(null);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const handleSave = async () => {
|
|
364
|
+
setSaving(true);
|
|
365
|
+
setError(null);
|
|
366
|
+
try {
|
|
367
|
+
const res = await fetch(
|
|
368
|
+
agentNativePath(`/_agent-native/extensions/${extension.id}`),
|
|
369
|
+
{
|
|
370
|
+
method: "PUT",
|
|
371
|
+
headers: { "Content-Type": "application/json" },
|
|
372
|
+
body: JSON.stringify({ content: code }),
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
if (!res.ok) {
|
|
376
|
+
const body = await res.json().catch(() => ({}));
|
|
377
|
+
throw new Error(body?.error ?? `Save failed (${res.status})`);
|
|
378
|
+
}
|
|
379
|
+
setOpen(false);
|
|
380
|
+
queryClient.setQueryData<Extension>(["extension", extension.id], (old) =>
|
|
381
|
+
old ? { ...old, content: code } : old,
|
|
382
|
+
);
|
|
383
|
+
queryClient.invalidateQueries({
|
|
384
|
+
queryKey: ["extension", extension.id],
|
|
385
|
+
});
|
|
386
|
+
queryClient.invalidateQueries({ queryKey: ["extensions"] });
|
|
387
|
+
onSaved?.();
|
|
388
|
+
} catch (err: any) {
|
|
389
|
+
setError(err?.message ?? "Save failed");
|
|
390
|
+
} finally {
|
|
391
|
+
setSaving(false);
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<>
|
|
397
|
+
<Tooltip>
|
|
398
|
+
<TooltipTrigger asChild>
|
|
399
|
+
<button
|
|
400
|
+
type="button"
|
|
401
|
+
onClick={() => setOpen(true)}
|
|
402
|
+
className="inline-flex items-center justify-center rounded-md h-8 w-8 text-muted-foreground hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
|
403
|
+
>
|
|
404
|
+
<IconCode className="h-4 w-4" />
|
|
405
|
+
</button>
|
|
406
|
+
</TooltipTrigger>
|
|
407
|
+
<TooltipContent>View / edit source</TooltipContent>
|
|
408
|
+
</Tooltip>
|
|
409
|
+
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
410
|
+
<DialogContent className="flex h-[85vh] w-[90vw] max-w-[900px] flex-col gap-0 overflow-hidden p-0">
|
|
411
|
+
<div className="flex shrink-0 items-center border-b border-border px-5 py-3 pr-12">
|
|
412
|
+
<DialogTitle className="truncate text-sm font-medium">
|
|
413
|
+
{extension.name} — source
|
|
414
|
+
</DialogTitle>
|
|
415
|
+
</div>
|
|
416
|
+
<textarea
|
|
417
|
+
className="flex-1 resize-none bg-muted/40 px-5 py-4 font-mono text-xs leading-relaxed text-foreground focus:outline-none"
|
|
418
|
+
value={code}
|
|
419
|
+
onChange={(e) => setCode(e.target.value)}
|
|
420
|
+
spellCheck={false}
|
|
421
|
+
/>
|
|
422
|
+
<div className="flex shrink-0 items-center justify-between border-t border-border px-5 py-3">
|
|
423
|
+
{error ? (
|
|
424
|
+
<p className="text-xs text-destructive">{error}</p>
|
|
425
|
+
) : (
|
|
426
|
+
<span className="text-xs text-muted-foreground">
|
|
427
|
+
Alpine.js / HTML · {code.length.toLocaleString()} chars
|
|
428
|
+
</span>
|
|
429
|
+
)}
|
|
430
|
+
<div className="flex items-center gap-2">
|
|
431
|
+
<button
|
|
432
|
+
type="button"
|
|
433
|
+
onClick={handleCancel}
|
|
434
|
+
className="inline-flex h-8 cursor-pointer items-center rounded-md border border-input px-3 text-xs hover:bg-accent"
|
|
435
|
+
>
|
|
436
|
+
Cancel
|
|
437
|
+
</button>
|
|
438
|
+
<button
|
|
439
|
+
type="button"
|
|
440
|
+
onClick={handleSave}
|
|
441
|
+
disabled={saving}
|
|
442
|
+
className="inline-flex h-8 cursor-pointer items-center rounded-md bg-primary px-4 text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
|
443
|
+
>
|
|
444
|
+
{saving ? "Saving…" : "Save"}
|
|
445
|
+
</button>
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
</DialogContent>
|
|
449
|
+
</Dialog>
|
|
450
|
+
</>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
327
454
|
function EditToolPopover({
|
|
328
455
|
extension,
|
|
329
456
|
onOpenChange,
|
|
@@ -1157,6 +1284,12 @@ export function ExtensionViewer({ extensionId }: ExtensionViewerProps) {
|
|
|
1157
1284
|
onRestored={() => setRefreshKey((k) => k + 1)}
|
|
1158
1285
|
onOpenChange={onPopoverOpenChange}
|
|
1159
1286
|
/>
|
|
1287
|
+
{extension.canEdit && (
|
|
1288
|
+
<SourceCodeDialog
|
|
1289
|
+
extension={extension}
|
|
1290
|
+
onSaved={() => setRefreshKey((k) => k + 1)}
|
|
1291
|
+
/>
|
|
1292
|
+
)}
|
|
1160
1293
|
<EditToolPopover
|
|
1161
1294
|
extension={extension}
|
|
1162
1295
|
onOpenChange={onPopoverOpenChange}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
IconChevronDown,
|
|
3
3
|
IconPlus,
|
|
4
|
-
|
|
4
|
+
IconFilter,
|
|
5
5
|
IconStar,
|
|
6
6
|
IconStarFilled,
|
|
7
7
|
IconTrash,
|
|
@@ -570,7 +570,7 @@ function ExtensionSortMenu({
|
|
|
570
570
|
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/45 opacity-0 transition-all hover:bg-accent hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring group-hover/extensions-section:opacity-100"
|
|
571
571
|
aria-label={copy.sortOptions}
|
|
572
572
|
>
|
|
573
|
-
<
|
|
573
|
+
<IconFilter className="h-3.5 w-3.5" />
|
|
574
574
|
</button>
|
|
575
575
|
</DropdownMenuTrigger>
|
|
576
576
|
</TooltipTrigger>
|
|
@@ -761,7 +761,7 @@ async function gzipReplayBody(body: string): Promise<Blob | null> {
|
|
|
761
761
|
.stream()
|
|
762
762
|
.pipeThrough(new CompressionStream("gzip"));
|
|
763
763
|
const compressed = await new Response(stream).arrayBuffer();
|
|
764
|
-
return new Blob([compressed], { type: "application/
|
|
764
|
+
return new Blob([compressed], { type: "application/octet-stream" });
|
|
765
765
|
} catch {
|
|
766
766
|
return null;
|
|
767
767
|
}
|
|
@@ -774,7 +774,7 @@ async function buildReplayUploadBody(body: string): Promise<ReplayUploadBody> {
|
|
|
774
774
|
body: compressed,
|
|
775
775
|
compressed: true,
|
|
776
776
|
headers: {
|
|
777
|
-
"Content-Type": "application/
|
|
777
|
+
"Content-Type": "application/octet-stream",
|
|
778
778
|
"Content-Encoding": "gzip",
|
|
779
779
|
},
|
|
780
780
|
};
|
|
@@ -226,11 +226,19 @@ export function buildExtensionHtml(
|
|
|
226
226
|
</style>
|
|
227
227
|
<style>
|
|
228
228
|
*, *::before, *::after { border-color: hsl(var(--border)); }
|
|
229
|
+
html, body {
|
|
230
|
+
/* Transparent so the iframe inherits the host surface (dashboard panel,
|
|
231
|
+
sidebar, chat) instead of painting the browser's default white canvas.
|
|
232
|
+
The dark class still flips the theme vars; content paints its own
|
|
233
|
+
bg-background / bg-card surfaces. */
|
|
234
|
+
background: transparent;
|
|
235
|
+
}
|
|
229
236
|
body {
|
|
230
237
|
--agent-native-extension-padding: clamp(16px, 2vw, 24px);
|
|
231
238
|
/* Legacy alias for pre-rename extension content (do not remove). */
|
|
232
239
|
--agent-native-tool-padding: var(--agent-native-extension-padding);
|
|
233
240
|
box-sizing: border-box;
|
|
241
|
+
color: hsl(var(--foreground));
|
|
234
242
|
font-family: 'Inter', sans-serif;
|
|
235
243
|
margin: 0;
|
|
236
244
|
min-height: 100vh;
|
|
@@ -403,8 +411,14 @@ export function buildExtensionHtml(
|
|
|
403
411
|
}
|
|
404
412
|
|
|
405
413
|
if (!res.ok) {
|
|
406
|
-
var err = res.body || {
|
|
407
|
-
|
|
414
|
+
var err = res.body || {};
|
|
415
|
+
var rawError = typeof err === 'string' ? err : err.error;
|
|
416
|
+
var message = (typeof rawError === 'string' && rawError.trim())
|
|
417
|
+
? rawError
|
|
418
|
+
: (res.status === 404
|
|
419
|
+
? "Action '" + name + "' is not available over HTTP (404). It may be agent-only (http: false); expose it with an HTTP-mounted action to call it from an extension."
|
|
420
|
+
: "Action '" + name + "' failed (" + (res.status || 'network error') + ")");
|
|
421
|
+
throw new Error(message);
|
|
408
422
|
}
|
|
409
423
|
return res.body;
|
|
410
424
|
}
|
|
@@ -730,7 +744,7 @@ export function buildExtensionHtml(
|
|
|
730
744
|
});
|
|
731
745
|
</script>
|
|
732
746
|
</head>
|
|
733
|
-
<body${extensionId ? ` data-extension-id="${extensionIdAttr}" data-tool-id="${extensionIdAttr}"` : ""} class="
|
|
747
|
+
<body${extensionId ? ` data-extension-id="${extensionIdAttr}" data-tool-id="${extensionIdAttr}"` : ""} class="text-foreground">
|
|
734
748
|
${content}
|
|
735
749
|
<div id="__extension-error-toast">
|
|
736
750
|
<div style="display:flex;align-items:flex-start;gap:8px;">
|
|
@@ -1,3 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical set of semantic theme token names shared by the app and extension
|
|
3
|
+
* iframes. This is the single source of truth: `getThemeVars` bakes default
|
|
4
|
+
* values for these into every iframe, and the host reads the *actual* computed
|
|
5
|
+
* values for the same names to sync its live palette into embedded extensions
|
|
6
|
+
* (see EmbeddedExtension). Custom properties can't be enumerated portably from
|
|
7
|
+
* a computed style, so the names must be listed explicitly.
|
|
8
|
+
*/
|
|
9
|
+
export const THEME_VAR_NAMES = [
|
|
10
|
+
"--background",
|
|
11
|
+
"--foreground",
|
|
12
|
+
"--card",
|
|
13
|
+
"--card-foreground",
|
|
14
|
+
"--popover",
|
|
15
|
+
"--popover-foreground",
|
|
16
|
+
"--primary",
|
|
17
|
+
"--primary-foreground",
|
|
18
|
+
"--secondary",
|
|
19
|
+
"--secondary-foreground",
|
|
20
|
+
"--muted",
|
|
21
|
+
"--muted-foreground",
|
|
22
|
+
"--accent",
|
|
23
|
+
"--accent-foreground",
|
|
24
|
+
"--destructive",
|
|
25
|
+
"--destructive-foreground",
|
|
26
|
+
"--border",
|
|
27
|
+
"--input",
|
|
28
|
+
"--ring",
|
|
29
|
+
"--radius",
|
|
30
|
+
"--sidebar-background",
|
|
31
|
+
"--sidebar-foreground",
|
|
32
|
+
"--sidebar-primary",
|
|
33
|
+
"--sidebar-primary-foreground",
|
|
34
|
+
"--sidebar-accent",
|
|
35
|
+
"--sidebar-accent-foreground",
|
|
36
|
+
"--sidebar-border",
|
|
37
|
+
"--sidebar-ring",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
1
40
|
/**
|
|
2
41
|
* CSS variables baked into every extension iframe. Both light and dark are
|
|
3
42
|
* always emitted — the `.dark` class on the iframe's `<html>` toggles between
|
|
@@ -213,7 +213,14 @@ export default defineAction({
|
|
|
213
213
|
"Enable cursor-based pagination. After each response, reads cursorPath from the JSON body and re-issues the request with cursorParam or cursorBodyPath set, accumulating items from itemsPath (or whole bodies) until cursor is empty or maxPages is reached. Combine with saveToFile to write the full dataset to a workspace file; use scratch/... for temporary staging.",
|
|
214
214
|
),
|
|
215
215
|
}),
|
|
216
|
-
|
|
216
|
+
// Mounted over HTTP (POST) so the agent and frontend (`useActionMutation`)
|
|
217
|
+
// can reach it, consistent with provider-api-catalog / provider-api-docs.
|
|
218
|
+
// toolCallable: false blocks the extension iframe bridge — this action
|
|
219
|
+
// accepts arbitrary methods (PUT/DELETE/etc.) against provider APIs, so
|
|
220
|
+
// allowing shared extensions to call it would let malicious extension authors
|
|
221
|
+
// mutate provider data under the opener's credentials.
|
|
222
|
+
http: { method: "POST" },
|
|
223
|
+
toolCallable: false,
|
|
217
224
|
run: async (args) => {
|
|
218
225
|
if (args.stageAs) {
|
|
219
226
|
const ctx = requireRequestCredentialContext("provider-api staging");
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
IconStar,
|
|
8
8
|
IconPencil,
|
|
9
9
|
IconSettings,
|
|
10
|
+
IconFilter,
|
|
10
11
|
IconGripVertical,
|
|
11
12
|
IconBook2,
|
|
12
13
|
IconDatabase,
|
|
@@ -251,7 +252,7 @@ function SidebarSectionSettingsPopover({
|
|
|
251
252
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground/65 opacity-0 transition-all hover:bg-sidebar-accent hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring group-hover/section:opacity-100 data-[state=open]:opacity-100"
|
|
252
253
|
aria-label={settingsLabel}
|
|
253
254
|
>
|
|
254
|
-
<
|
|
255
|
+
<IconFilter className="h-3.5 w-3.5" />
|
|
255
256
|
</button>
|
|
256
257
|
</PopoverTrigger>
|
|
257
258
|
</TooltipTrigger>
|
|
@@ -3,6 +3,7 @@ import { useDraggable } from "@dnd-kit/core";
|
|
|
3
3
|
import {
|
|
4
4
|
IconGripVertical,
|
|
5
5
|
IconDotsVertical,
|
|
6
|
+
IconExternalLink,
|
|
6
7
|
IconMaximize,
|
|
7
8
|
IconPencil,
|
|
8
9
|
IconRefresh,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
} from "@tabler/icons-react";
|
|
13
14
|
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
|
14
15
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
16
|
+
import { useNavigate } from "react-router";
|
|
15
17
|
|
|
16
18
|
import { ChartFillHeight, SqlChart } from "@/components/dashboard/SqlChart";
|
|
17
19
|
import {
|
|
@@ -119,6 +121,8 @@ export function SqlChartCard({
|
|
|
119
121
|
|
|
120
122
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
121
123
|
const [expanded, setExpanded] = useState(false);
|
|
124
|
+
const [extRefreshKey, setExtRefreshKey] = useState(0);
|
|
125
|
+
const navigate = useNavigate();
|
|
122
126
|
const [exportCsv, setExportCsv] = useState<(() => void) | null>(null);
|
|
123
127
|
const [shouldLoadData, setShouldLoadData] = useState(
|
|
124
128
|
eagerLoad ||
|
|
@@ -291,10 +295,12 @@ export function SqlChartCard({
|
|
|
291
295
|
}
|
|
292
296
|
|
|
293
297
|
// Extension panels render their sandboxed iframe full-bleed with no card chrome
|
|
294
|
-
// or title — the extension owns its own UI.
|
|
295
|
-
//
|
|
296
|
-
//
|
|
298
|
+
// or title — the extension owns its own UI. All viewers get the read-only
|
|
299
|
+
// actions (full screen, refresh, open extension); editable dashboards also get
|
|
300
|
+
// delete and drag.
|
|
297
301
|
if (panel.chartType === "extension") {
|
|
302
|
+
const extensionId = (panel.config as Record<string, unknown> | undefined)
|
|
303
|
+
?.extensionId as string | undefined;
|
|
298
304
|
return (
|
|
299
305
|
<div
|
|
300
306
|
ref={setCardNodeRef}
|
|
@@ -302,45 +308,97 @@ export function SqlChartCard({
|
|
|
302
308
|
data-dragging={isDragSource ? "true" : undefined}
|
|
303
309
|
className="dashboard-extension-card group relative h-full"
|
|
304
310
|
>
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
311
|
+
{!expanded && (
|
|
312
|
+
<SqlChart
|
|
313
|
+
key={extRefreshKey}
|
|
314
|
+
panel={panel}
|
|
315
|
+
resolvedSql={resolvedSql}
|
|
316
|
+
loadData
|
|
317
|
+
/>
|
|
318
|
+
)}
|
|
319
|
+
<div className="absolute right-1 top-1 flex items-center gap-1 opacity-0 group-hover:opacity-100">
|
|
320
|
+
<DropdownMenu>
|
|
321
|
+
<Tooltip>
|
|
322
|
+
<TooltipTrigger asChild>
|
|
323
|
+
<DropdownMenuTrigger asChild>
|
|
324
|
+
<button
|
|
325
|
+
className="p-1 rounded bg-background/80 text-muted-foreground hover:text-foreground"
|
|
326
|
+
aria-label={t("sqlDashboard.panelOptions")}
|
|
327
|
+
>
|
|
328
|
+
<IconDotsVertical className="h-3.5 w-3.5" />
|
|
329
|
+
</button>
|
|
330
|
+
</DropdownMenuTrigger>
|
|
331
|
+
</TooltipTrigger>
|
|
332
|
+
<TooltipContent>{t("sqlDashboard.panelOptions")}</TooltipContent>
|
|
333
|
+
</Tooltip>
|
|
334
|
+
<DropdownMenuContent align="end" className="w-44">
|
|
335
|
+
<DropdownMenuItem onSelect={() => setExpanded(true)}>
|
|
336
|
+
<IconMaximize className="h-4 w-4 mr-2" />
|
|
337
|
+
{t("sqlDashboard.fullScreen")}
|
|
338
|
+
</DropdownMenuItem>
|
|
339
|
+
{extensionId ? (
|
|
325
340
|
<DropdownMenuItem
|
|
326
|
-
onSelect={(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
341
|
+
onSelect={() =>
|
|
342
|
+
navigate(
|
|
343
|
+
`/extensions/${extensionId}/${encodeURIComponent(
|
|
344
|
+
(panel.title ?? "extension")
|
|
345
|
+
.toLowerCase()
|
|
346
|
+
.replace(/[^a-z0-9]+/g, "-"),
|
|
347
|
+
)}`,
|
|
348
|
+
)
|
|
349
|
+
}
|
|
330
350
|
>
|
|
331
|
-
<
|
|
332
|
-
{
|
|
351
|
+
<IconExternalLink className="h-4 w-4 mr-2" />
|
|
352
|
+
Open extension {/* i18n-ignore */}
|
|
333
353
|
</DropdownMenuItem>
|
|
334
|
-
|
|
335
|
-
|
|
354
|
+
) : null}
|
|
355
|
+
<DropdownMenuSeparator />
|
|
356
|
+
<DropdownMenuItem onSelect={() => setExtRefreshKey((k) => k + 1)}>
|
|
357
|
+
<IconRefresh className="h-4 w-4 mr-2" />
|
|
358
|
+
{t("sqlDashboard.refresh")}
|
|
359
|
+
</DropdownMenuItem>
|
|
360
|
+
{editable ? (
|
|
361
|
+
<>
|
|
362
|
+
<DropdownMenuSeparator />
|
|
363
|
+
<DropdownMenuItem
|
|
364
|
+
onSelect={(e) => {
|
|
365
|
+
e.preventDefault();
|
|
366
|
+
setConfirmOpen(true);
|
|
367
|
+
}}
|
|
368
|
+
>
|
|
369
|
+
<IconTrash className="h-4 w-4 mr-2" />
|
|
370
|
+
{t("sidebar.delete")}
|
|
371
|
+
</DropdownMenuItem>
|
|
372
|
+
</>
|
|
373
|
+
) : null}
|
|
374
|
+
</DropdownMenuContent>
|
|
375
|
+
</DropdownMenu>
|
|
376
|
+
{editable ? (
|
|
336
377
|
<PanelDragHandle
|
|
337
378
|
panelId={panel.id}
|
|
338
379
|
label={t("sqlDashboard.dragToReorder")}
|
|
339
380
|
className="p-1 rounded bg-background/80 cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-muted-foreground"
|
|
340
381
|
iconClassName="h-3.5 w-3.5"
|
|
341
382
|
/>
|
|
342
|
-
|
|
343
|
-
|
|
383
|
+
) : null}
|
|
384
|
+
</div>
|
|
385
|
+
<Dialog open={expanded} onOpenChange={setExpanded}>
|
|
386
|
+
<DialogContent className="flex h-[90vh] w-[95vw] max-w-[1400px] flex-col gap-4">
|
|
387
|
+
<DialogHeader className="shrink-0 pr-8 text-left">
|
|
388
|
+
<DialogTitle className="truncate">{panel.title}</DialogTitle>
|
|
389
|
+
</DialogHeader>
|
|
390
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-auto">
|
|
391
|
+
<ChartFillHeight>
|
|
392
|
+
<SqlChart
|
|
393
|
+
key={extRefreshKey}
|
|
394
|
+
panel={panel}
|
|
395
|
+
resolvedSql={resolvedSql}
|
|
396
|
+
loadData
|
|
397
|
+
/>
|
|
398
|
+
</ChartFillHeight>
|
|
399
|
+
</div>
|
|
400
|
+
</DialogContent>
|
|
401
|
+
</Dialog>
|
|
344
402
|
{editable ? (
|
|
345
403
|
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
|
346
404
|
<AlertDialogContent>
|
|
@@ -82,11 +82,36 @@ function statusError(message: string, statusCode: number): Error {
|
|
|
82
82
|
return Object.assign(new Error(message), { statusCode });
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
function looksLikeDecodedJson(bytes: Buffer): boolean {
|
|
86
|
+
const first = bytes.toString("utf8").trimStart()[0];
|
|
87
|
+
return first === "{" || first === "[";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function tryGunzip(bytes: Buffer): Buffer | null {
|
|
91
|
+
try {
|
|
92
|
+
return gunzipSync(bytes);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function decodeTextWrappedGzip(
|
|
99
|
+
bytes: Buffer,
|
|
100
|
+
): { decoded: Buffer; requestBytes: number } | null {
|
|
101
|
+
const text = bytes.toString("utf8");
|
|
102
|
+
const binaryStringBytes = Buffer.from(text, "latin1");
|
|
103
|
+
if (binaryStringBytes.equals(bytes)) return null;
|
|
104
|
+
const decoded = tryGunzip(binaryStringBytes);
|
|
105
|
+
return decoded
|
|
106
|
+
? { decoded, requestBytes: binaryStringBytes.byteLength }
|
|
107
|
+
: null;
|
|
108
|
+
}
|
|
109
|
+
|
|
85
110
|
export function decodeSessionReplayRequestBody(
|
|
86
111
|
rawBody: Buffer | Uint8Array | string | undefined,
|
|
87
112
|
contentEncoding?: string | null,
|
|
88
113
|
): { body: unknown; requestBytes: number } {
|
|
89
|
-
|
|
114
|
+
let requestBytes =
|
|
90
115
|
typeof rawBody === "string"
|
|
91
116
|
? Buffer.byteLength(rawBody, "utf8")
|
|
92
117
|
: (rawBody?.byteLength ?? 0);
|
|
@@ -99,10 +124,26 @@ export function decodeSessionReplayRequestBody(
|
|
|
99
124
|
let decoded = bytes;
|
|
100
125
|
|
|
101
126
|
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
127
|
+
const gunzipped = tryGunzip(bytes);
|
|
128
|
+
if (gunzipped) {
|
|
129
|
+
decoded = gunzipped;
|
|
130
|
+
} else {
|
|
131
|
+
// Netlify may hand Nitro an already-decoded body while preserving the
|
|
132
|
+
// original browser Content-Encoding header.
|
|
133
|
+
if (looksLikeDecodedJson(bytes)) {
|
|
134
|
+
decoded = bytes;
|
|
135
|
+
} else {
|
|
136
|
+
// Some Netlify paths wrap binary request bodies in a JS string before
|
|
137
|
+
// Nitro reads them back as UTF-8. Reinterpret that text as one-byte
|
|
138
|
+
// binary data so real browser CompressionStream uploads survive.
|
|
139
|
+
const textWrappedGzip = decodeTextWrappedGzip(bytes);
|
|
140
|
+
if (textWrappedGzip) {
|
|
141
|
+
decoded = textWrappedGzip.decoded;
|
|
142
|
+
requestBytes = textWrappedGzip.requestBytes;
|
|
143
|
+
} else {
|
|
144
|
+
throw statusError("Invalid gzip-compressed replay body", 400);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
106
147
|
}
|
|
107
148
|
} else if (encoding && encoding !== "identity") {
|
|
108
149
|
throw statusError(
|
|
@@ -9,6 +9,7 @@ interface TimeSlotPickerProps {
|
|
|
9
9
|
selectedSlot: string | null;
|
|
10
10
|
onSelect: (start: string) => void;
|
|
11
11
|
loading?: boolean;
|
|
12
|
+
errorMessage?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export function TimeSlotPicker({
|
|
@@ -16,6 +17,7 @@ export function TimeSlotPicker({
|
|
|
16
17
|
selectedSlot,
|
|
17
18
|
onSelect,
|
|
18
19
|
loading,
|
|
20
|
+
errorMessage,
|
|
19
21
|
}: TimeSlotPickerProps) {
|
|
20
22
|
const t = useT();
|
|
21
23
|
|
|
@@ -29,6 +31,14 @@ export function TimeSlotPicker({
|
|
|
29
31
|
);
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
if (errorMessage) {
|
|
35
|
+
return (
|
|
36
|
+
<p className="rounded-lg border border-destructive/30 bg-destructive/[0.06] px-3 py-3 text-sm text-destructive">
|
|
37
|
+
{errorMessage}
|
|
38
|
+
</p>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
if (slots.length === 0) {
|
|
33
43
|
return (
|
|
34
44
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
useDisconnectGoogle,
|
|
34
34
|
type DesktopAuthIssue,
|
|
35
35
|
} from "@/hooks/use-google-auth";
|
|
36
|
+
import { shouldOfferGoogleOAuthSetup } from "@/lib/google-oauth-setup";
|
|
36
37
|
|
|
37
38
|
interface EnvKeyStatus {
|
|
38
39
|
key: string;
|
|
@@ -93,6 +94,7 @@ export function GoogleConnectBanner({
|
|
|
93
94
|
|
|
94
95
|
const accounts = googleStatus.data?.accounts ?? [];
|
|
95
96
|
const hasAccounts = accounts.length > 0;
|
|
97
|
+
const canOfferOAuthSetup = useMemo(() => shouldOfferGoogleOAuthSetup(), []);
|
|
96
98
|
|
|
97
99
|
const isBuilderFrame = useMemo(() => isInBuilderFrame(), []);
|
|
98
100
|
const authPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
@@ -184,10 +186,17 @@ export function GoogleConnectBanner({
|
|
|
184
186
|
useEffect(() => {
|
|
185
187
|
if (authUrl.error) {
|
|
186
188
|
setWantAuthUrl(false);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
+
if (canOfferOAuthSetup) {
|
|
190
|
+
setShowWizard(true);
|
|
191
|
+
fetchStatus();
|
|
192
|
+
} else {
|
|
193
|
+
setDesktopAuthIssue({
|
|
194
|
+
code: "managed_credentials_unavailable",
|
|
195
|
+
message: t("googleConnect.managedCredentialsUnavailableDescription"),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
189
198
|
}
|
|
190
|
-
}, [authUrl.error, fetchStatus]);
|
|
199
|
+
}, [authUrl.error, canOfferOAuthSetup, fetchStatus, t]);
|
|
191
200
|
|
|
192
201
|
useEffect(() => {
|
|
193
202
|
if (
|
|
@@ -388,7 +397,7 @@ export function GoogleConnectBanner({
|
|
|
388
397
|
</div>
|
|
389
398
|
)}
|
|
390
399
|
|
|
391
|
-
{showWizard && !allConfigured && (
|
|
400
|
+
{showWizard && !allConfigured && canOfferOAuthSetup && (
|
|
392
401
|
<div className="mt-8 w-full max-w-lg text-start">
|
|
393
402
|
<SetupWizard
|
|
394
403
|
currentStep={currentStep}
|
|
@@ -481,7 +490,7 @@ export function GoogleConnectBanner({
|
|
|
481
490
|
</div>
|
|
482
491
|
|
|
483
492
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
484
|
-
{showWizard && !allConfigured ? (
|
|
493
|
+
{showWizard && !allConfigured && canOfferOAuthSetup ? (
|
|
485
494
|
<Button
|
|
486
495
|
size="sm"
|
|
487
496
|
variant="outline"
|
|
@@ -541,7 +550,7 @@ export function GoogleConnectBanner({
|
|
|
541
550
|
/>
|
|
542
551
|
|
|
543
552
|
{/* Inline setup wizard */}
|
|
544
|
-
{showWizard && !allConfigured && (
|
|
553
|
+
{showWizard && !allConfigured && canOfferOAuthSetup && (
|
|
545
554
|
<div className="px-5 pb-4 pt-1 max-w-2xl">
|
|
546
555
|
<p className="text-xs text-muted-foreground mb-3">
|
|
547
556
|
{t("googleConnect.followSteps")}
|