@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.
Files changed (75) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +19 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/run-store.ts +180 -13
  5. package/corpus/core/src/client/AssistantChat.tsx +49 -0
  6. package/corpus/core/src/client/active-run-state.ts +30 -0
  7. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +41 -3
  8. package/corpus/core/src/client/extensions/ExtensionViewer.tsx +133 -0
  9. package/corpus/core/src/client/extensions/ExtensionsSidebarSection.tsx +2 -2
  10. package/corpus/core/src/client/session-replay.ts +2 -2
  11. package/corpus/core/src/extensions/html-shell.ts +17 -3
  12. package/corpus/core/src/extensions/theme.ts +39 -0
  13. package/corpus/templates/analytics/actions/provider-api-request.ts +8 -1
  14. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +2 -1
  15. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +91 -33
  16. package/corpus/templates/analytics/changelog/2026-07-01-session-replay-recordings-upload-reliably-in-production.md +6 -0
  17. package/corpus/templates/analytics/server/handlers/session-replay.ts +46 -5
  18. package/corpus/templates/calendar/app/components/booking/TimeSlotPicker.tsx +10 -0
  19. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +15 -6
  20. package/corpus/templates/calendar/app/components/calendar/GoogleSetupWizard.tsx +22 -0
  21. package/corpus/templates/calendar/app/hooks/use-bookings.ts +6 -2
  22. package/corpus/templates/calendar/app/i18n/zh-TW.ts +4 -0
  23. package/corpus/templates/calendar/app/i18n-data.ts +107 -0
  24. package/corpus/templates/calendar/app/lib/google-oauth-setup.ts +17 -0
  25. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +9 -1
  26. package/corpus/templates/calendar/app/pages/BookingPage.tsx +40 -26
  27. package/corpus/templates/calendar/app/pages/Settings.tsx +3 -1
  28. package/corpus/templates/calendar/changelog/2026-07-01-booking-links-now-show-an-error-when-calendar-availability-c.md +6 -0
  29. package/corpus/templates/calendar/server/handlers/bookings.ts +78 -13
  30. package/corpus/templates/calendar/server/handlers/google-auth.ts +3 -2
  31. package/corpus/templates/calendar/server/lib/google-calendar.ts +16 -2
  32. package/corpus/templates/clips/changelog/2026-07-01-chrome-recording-uploads-now-serialize-chunks-to-avoid-resum.md +6 -0
  33. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  34. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +30 -23
  35. package/corpus/templates/design/app/lib/design-system-preview.ts +69 -0
  36. package/corpus/templates/design/app/pages/DesignSystems.tsx +71 -50
  37. package/corpus/templates/design/changelog/2026-07-01-design-systems-no-longer-crash-on-responsive-tokens.md +6 -0
  38. package/dist/agent/run-store.d.ts +19 -6
  39. package/dist/agent/run-store.d.ts.map +1 -1
  40. package/dist/agent/run-store.js +163 -13
  41. package/dist/agent/run-store.js.map +1 -1
  42. package/dist/client/AssistantChat.d.ts +2 -0
  43. package/dist/client/AssistantChat.d.ts.map +1 -1
  44. package/dist/client/AssistantChat.js +36 -2
  45. package/dist/client/AssistantChat.js.map +1 -1
  46. package/dist/client/active-run-state.d.ts +3 -0
  47. package/dist/client/active-run-state.d.ts.map +1 -1
  48. package/dist/client/active-run-state.js +24 -0
  49. package/dist/client/active-run-state.js.map +1 -1
  50. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  51. package/dist/client/extensions/EmbeddedExtension.js +32 -3
  52. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  53. package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
  54. package/dist/client/extensions/ExtensionViewer.js +59 -2
  55. package/dist/client/extensions/ExtensionViewer.js.map +1 -1
  56. package/dist/client/extensions/ExtensionsSidebarSection.js +2 -2
  57. package/dist/client/extensions/ExtensionsSidebarSection.js.map +1 -1
  58. package/dist/client/session-replay.js +2 -2
  59. package/dist/client/session-replay.js.map +1 -1
  60. package/dist/collab/routes.d.ts +2 -2
  61. package/dist/extensions/html-shell.d.ts.map +1 -1
  62. package/dist/extensions/html-shell.js +17 -3
  63. package/dist/extensions/html-shell.js.map +1 -1
  64. package/dist/extensions/theme.d.ts +9 -0
  65. package/dist/extensions/theme.d.ts.map +1 -1
  66. package/dist/extensions/theme.js +38 -0
  67. package/dist/extensions/theme.js.map +1 -1
  68. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  69. package/dist/notifications/routes.d.ts +2 -2
  70. package/dist/observability/routes.d.ts +8 -8
  71. package/dist/progress/routes.d.ts +1 -1
  72. package/dist/resources/handlers.d.ts +3 -3
  73. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  74. package/dist/server/transcribe-voice.d.ts +1 -1
  75. 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 &middot; {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
- IconSettings,
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
- <IconSettings className="h-3.5 w-3.5" />
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/json" });
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/json",
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 || { error: res.statusText };
407
- throw new Error(err.error || 'Action failed: ' + res.status);
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="bg-background text-foreground">
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
- http: false,
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
- <IconSettings className="h-3.5 w-3.5" />
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. Editable dashboards still get a
295
- // hover overlay for delete/drag. Editing routes through the agent (the manual
296
- // panel editor has no extension picker), so no inline edit action here.
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
- <SqlChart panel={panel} resolvedSql={resolvedSql} loadData />
306
- {editable ? (
307
- <div className="absolute right-1 top-1 flex items-center gap-1 opacity-0 group-hover:opacity-100">
308
- <DropdownMenu>
309
- <Tooltip>
310
- <TooltipTrigger asChild>
311
- <DropdownMenuTrigger asChild>
312
- <button
313
- className="p-1 rounded bg-background/80 text-muted-foreground hover:text-foreground"
314
- aria-label={t("sqlDashboard.panelOptions")}
315
- >
316
- <IconDotsVertical className="h-3.5 w-3.5" />
317
- </button>
318
- </DropdownMenuTrigger>
319
- </TooltipTrigger>
320
- <TooltipContent>
321
- {t("sqlDashboard.panelOptions")}
322
- </TooltipContent>
323
- </Tooltip>
324
- <DropdownMenuContent align="end" className="w-40">
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={(e) => {
327
- e.preventDefault();
328
- setConfirmOpen(true);
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
- <IconTrash className="h-4 w-4 mr-2" />
332
- {t("sidebar.delete")}
351
+ <IconExternalLink className="h-4 w-4 mr-2" />
352
+ Open extension {/* i18n-ignore */}
333
353
  </DropdownMenuItem>
334
- </DropdownMenuContent>
335
- </DropdownMenu>
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
- </div>
343
- ) : null}
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>
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ Session replay recordings upload reliably in production.
@@ -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
- const requestBytes =
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
- try {
103
- decoded = gunzipSync(bytes);
104
- } catch {
105
- throw statusError("Invalid gzip-compressed replay body", 400);
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
- setShowWizard(true);
188
- fetchStatus();
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")}